diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf131c5..bf5bace 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,27 @@ jobs: - uses: actions/checkout@v7 - name: Run the tests - run: swift test + run: swift test --enable-code-coverage + + - name: Require 90% core line coverage + run: | + binary="$(find .build -type f -name VirtualGearsPackageTests -print -quit)" + report="$( + xcrun llvm-cov report "$binary" \ + -instr-profile=.build/debug/codecov/default.profdata \ + -ignore-filename-regex='Tests|Tools' + )" + printf '%s\n' "$report" + coverage="$( + printf '%s\n' "$report" | + awk '/^TOTAL/ { gsub("%", "", $10); print $10 }' + )" + awk -v coverage="$coverage" 'BEGIN { + if (coverage + 0 < 90) { + printf "Core line coverage %.2f%% is below 90%%\n", coverage + exit 1 + } + }' build: name: App build @@ -44,3 +64,17 @@ jobs: -scheme "${{ matrix.scheme }}" \ -destination 'generic/platform=iOS Simulator' \ CODE_SIGNING_ALLOWED=NO + + ui-tests: + name: UI tests + runs-on: macos-26 + steps: + - uses: actions/checkout@v7 + + - name: Run UI regression tests + run: | + xcodebuild test \ + -project VirtualGears.xcodeproj \ + -scheme VirtualGears \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -parallel-testing-enabled NO diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 276015a..242f4e1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -27,9 +27,42 @@ does not add any physical-hardware evidence. Run the hardware-independent test suite: ```bash -swift test +swift test --enable-code-coverage ``` +CI requires at least 90% line coverage for `Sources/VirtualGearsCore`, excluding +tests and hardware investigation tools. + +Run the simulator UI regression suite: + +```bash +xcodebuild test \ + -project VirtualGears.xcodeproj \ + -scheme VirtualGears \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -parallel-testing-enabled NO +``` + +`VirtualGearsUITests` launches deterministic debug fixtures rather than pretending +the simulator has Bluetooth hardware. Its 30 scenarios cover every primary +screen, portrait and landscape status visibility, Accessibility Dynamic Type, +startup failure, trainer reconnect, a riding app waiting, low Click battery, +pending shifts, accepted Click press feedback, navigation, stop confirmation and +cancellation, gear-mode switching, Headwind controls and Demo Mode interactions +in both shift directions. Six of them are regression guards with measured +assertions rather than existence checks: the ride status must be wide enough to +be read as words rather than collapsing to an icon, cancelling the stop +confirmation must return to the ride, every equipment status must sit on one +row, a low Click battery must be drawn at warning weight, the Easier/Harder +buttons in Demo Mode must be drawn with the same distinct visual weight as the +ride screen's (sampled by pixel colour, since button styling isn't exposed via +the accessibility tree), and the chain-position reminder must never appear or +disappear across startup states (it previously vanished the instant the +trainer connected, making the button above it jump). Screenshots are +attached to every test result. Protocol +behavior and equipment lifecycle remain covered by the package tests and +physical-hardware evidence. + Open the iPhone project: ```bash @@ -54,6 +87,7 @@ without code signing. | `Sources/VirtualGearsCore` | Gear calculations, trainer commands, ride coordination and other hardware-independent logic | | `VirtualGearsProduct` | SwiftUI screens and the real Bluetooth services | | `Tests/VirtualGearsCoreTests` | Hardware-independent unit and ride-lifecycle tests | +| `VirtualGearsUITests` | Simulator UI, navigation, accessibility and layout regression tests | | `Tools` | macOS tools for inspecting the KICKR, Zwift Click and advertised trainer name | | `docs` | MkDocs website, screenshots and hardware findings | | `VirtualGears.xcodeproj` | iPhone app project | diff --git a/README.md b/README.md index 21f4aaf..361329b 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,9 @@ supported. may show the iPhone's name instead. 4. Ride and shift with the large **Easier** and **Harder** buttons. -There is no start button and no setup wizard. If it finds more than one trainer, -it asks you to choose yours by name. +There is no setup wizard. Once the trainer is connected, tap **Start Shifting** +when you want Virtual Gears to apply its gears. If it finds more than one +trainer, it asks you to choose yours by name. The same rule applies to optional equipment: one Click or Headwind is used automatically; if several are found, Virtual Gears asks instead of guessing. @@ -75,6 +76,8 @@ with physical hardware. - **On-phone shifting** with large controls in portrait and landscape. - **Accessible ride controls** with VoiceOver gear feedback, adjustable gear control and support for larger text. +- **Persistent equipment statuses** that keep the trainer, Click, fan and + riding app visible independently, including while the riding app is waiting. - **Automatic optional-equipment discovery** for an original Zwift Click and Wahoo KICKR HEADWIND. - **Optional Zwift Click shifting** from the handlebar. diff --git a/Sources/VirtualGearsCore/AppConfiguration.swift b/Sources/VirtualGearsCore/AppConfiguration.swift index 7f8963c..ff0edbc 100644 --- a/Sources/VirtualGearsCore/AppConfiguration.swift +++ b/Sources/VirtualGearsCore/AppConfiguration.swift @@ -145,6 +145,9 @@ public extension AppConfiguration { guard drivetrain != nil else { return "Too wide a range for the trainer" } + if usesVirtualGears { + return "\(gearCount) gears · extra-low climbing range" + } return "\(gearCount) gears · \(rangeDescription)" } diff --git a/Sources/VirtualGearsCore/DrivetrainCatalog.swift b/Sources/VirtualGearsCore/DrivetrainCatalog.swift index 4695b71..fc9344d 100644 --- a/Sources/VirtualGearsCore/DrivetrainCatalog.swift +++ b/Sources/VirtualGearsCore/DrivetrainCatalog.swift @@ -40,6 +40,14 @@ public struct CassetteOption: Identifiable, Equatable, Sendable { guard let smallest = cogs.first, let largest = cogs.last else { return "" } return "\(smallest)-\(largest)" } + + /// The name plus what tells it apart from the others that share it. Several + /// cassettes are called "11-28"; on a screen that lists them under their own + /// cog-count headings that is clear, but anywhere else — the selected value, + /// VoiceOver — it names three different parts at once. + public var qualifiedName: String { + "\(name) · \(speeds) cogs" + } } /// Real parts a rider can buy, so a saved setup shifts like the bike it names. diff --git a/Sources/VirtualGearsCore/GearRailPresentation.swift b/Sources/VirtualGearsCore/GearRailPresentation.swift new file mode 100644 index 0000000..af59e86 --- /dev/null +++ b/Sources/VirtualGearsCore/GearRailPresentation.swift @@ -0,0 +1,72 @@ +import Foundation + +/// How one gear is drawn on the position rail under the gear number. +/// +/// The rail used to be a row of identical dots with one of them coloured. That +/// can only be read by counting, which is not something a rider does at 90rpm, +/// so the rail said little the number above it had not already said. Filling in +/// the gears behind the rider turns it into a picture of where they are in the +/// range, which is the one thing the number cannot show. +public enum GearRailMarker: String, CaseIterable, Equatable, Sendable { + /// The gear the trainer has confirmed. + case selected + /// An easier gear the rider has already come up through. + case behind + /// A harder gear still available. + case ahead + /// The gear the rider has asked for and the trainer has not confirmed yet. + case requested + + /// How tall this marker is drawn, as a fraction of the tallest one. + /// + /// Every kind has its own height so the rail still reads when colour does + /// not: in greyscale, to a rider who cannot tell the colours apart, or + /// through a phone screen with sweat on it. + public var relativeHeight: Double { + switch self { + case .selected: 1 + case .requested: 0.75 + case .behind: 0.5 + case .ahead: 0.3 + } + } +} + +public enum GearRail { + /// Describes each position on the rail for a gear range of `count` gears. + /// + /// A requested gear is only ever drawn while it differs from the confirmed + /// one, so the rail never shows a gear the trainer has not agreed to as if + /// it were the gear being ridden. + public static func markers( + count: Int, + selected: Int?, + requested: Int? + ) -> [GearRailMarker] { + guard count > 0 else { return [] } + let range = 0.. Double { + min(24, max(15, primary * 0.09)) + } +} diff --git a/Sources/VirtualGearsCore/ProxyCoordinator.swift b/Sources/VirtualGearsCore/ProxyCoordinator.swift index 6bad937..2808ee7 100644 --- a/Sources/VirtualGearsCore/ProxyCoordinator.swift +++ b/Sources/VirtualGearsCore/ProxyCoordinator.swift @@ -120,6 +120,16 @@ public final class ProxyCoordinator { && (confirmedGearIndex ?? Int.max) < gearSequence.count - 1 } + /// Makes the transparent trainer proxy available without enabling gears. + /// + /// The riding app owns its ride. Virtual Gears can therefore pass trainer + /// data and commands through as soon as the KICKR is usable, while the rider + /// separately decides when to apply virtual shifting. + public func makeTrainerProxyAvailable() { + guard kickr.isReady else { return } + peripheral.startAdvertising() + } + public init( kickr: any TrainerLink, click: any ShifterLink, @@ -340,7 +350,7 @@ public final class ProxyCoordinator { hasAppliedVirtualGear = true defaults.set(wheelSize, forKey: interruptedShiftingKey) guard startMayProceed(id) else { throw CancellationError() } - peripheral.startAdvertising() + makeTrainerProxyAvailable() try await waitUntilPeripheralReady(shiftingID: id) lifecycle.markActive() log("Ride session started") @@ -1166,5 +1176,18 @@ extension ProxyCoordinator { TrainerSafety.referenceCircumferenceMillimeters updateDisplayedGear() } + + public func stageScreenshotFailure(_ message: String) { + lifecycle.refuseStart(message) + } + + public func stageScreenshotPendingShift() { + _ = gearEngine?.requestShift(by: 1) + requestedGearIndex = gearEngine?.requestedIndex + } + + public func stageScreenshotReconnecting() { + lifecycle.markReconnecting() + } } #endif diff --git a/Tests/VirtualGearsCoreTests/AppConfigurationTests.swift b/Tests/VirtualGearsCoreTests/AppConfigurationTests.swift index 86a5133..5f9784e 100644 --- a/Tests/VirtualGearsCoreTests/AppConfigurationTests.swift +++ b/Tests/VirtualGearsCoreTests/AppConfigurationTests.swift @@ -75,6 +75,19 @@ final class AppConfigurationTests: XCTestCase { XCTAssertTrue(configuration.canFinishSetup) } + func testForgettingAHeadwindClearsBothStoredFields() { + var configuration = trainerReady() + configuration.rememberHeadwind(named: "HEADWIND 9267", id: UUID()) + XCTAssertTrue(configuration.usesHeadwind) + + configuration.forgetHeadwind() + + XCTAssertNil(configuration.headwindName) + XCTAssertNil(configuration.headwindUUID) + XCTAssertFalse(configuration.usesHeadwind) + XCTAssertTrue(configuration.canFinishSetup) + } + // MARK: - Gears the trainer can actually copy func testTheStartingChoiceIsSafeWithoutTheRiderTouchingAnything() { @@ -83,6 +96,10 @@ final class AppConfigurationTests: XCTestCase { XCTAssertNotNil(configuration.drivetrain) XCTAssertTrue(configuration.hasSafeCircumference) XCTAssertEqual(configuration.drivetrainName, "Virtual gears") + XCTAssertEqual( + configuration.gearSummary, + "24 gears · extra-low climbing range" + ) } /// Gears wider than the trainer can copy must block a ride rather than be @@ -145,6 +162,37 @@ final class AppConfigurationTests: XCTestCase { XCTAssertEqual(configuration.gearCount, drivetrain.gears.count) } + func testRealDrivetrainsUsePartNamesAndDescribeTheirRange() { + let expectedDescriptions = [ + "close together, for flat roads", + "a normal road spread", + "wide, with easy climbing gears", + "very wide, for steep climbs", + ] + var observedDescriptions = Set() + + for chainring in DrivetrainCatalog.chainrings { + for cassette in DrivetrainCatalog.cassettes { + var configuration = trainerReady() + configuration.usesVirtualGears = false + configuration.chainringID = chainring.id + configuration.cassetteID = cassette.id + guard configuration.drivetrain != nil else { continue } + + XCTAssertEqual( + configuration.drivetrainName, + "\(chainring.name) · \(cassette.name)" + ) + for description in expectedDescriptions + where configuration.gearSummary.contains(description) { + observedDescriptions.insert(description) + } + } + } + + XCTAssertEqual(observedDescriptions, Set(expectedDescriptions)) + } + // MARK: - Surviving an update /// The configuration is stored on the phone between launches, so a rider diff --git a/Tests/VirtualGearsCoreTests/HeadwindProtocolTests.swift b/Tests/VirtualGearsCoreTests/HeadwindProtocolTests.swift index e99fbe4..3ba243c 100644 --- a/Tests/VirtualGearsCoreTests/HeadwindProtocolTests.swift +++ b/Tests/VirtualGearsCoreTests/HeadwindProtocolTests.swift @@ -2,6 +2,21 @@ import XCTest @testable import VirtualGearsCore final class HeadwindProtocolTests: XCTestCase { + func testEveryModeDescribesItsControlBehavior() { + let expectations: [(HeadwindMode, String, Bool)] = [ + (.off, "Off", false), + (.heartRate, "Heart-rate sensor", true), + (.speed, "Speed sensor", true), + (.manual, "Manual", false), + (.sleep, "Sleeping", false), + ] + + for (mode, label, isSensorControlled) in expectations { + XCTAssertEqual(mode.label, label) + XCTAssertEqual(mode.isSensorControlled, isSensorControlled) + } + } + func testEncodesEveryModeCommand() throws { XCTAssertEqual( Array(try HeadwindCommand.setMode(.heartRate).encode()), @@ -63,5 +78,15 @@ final class HeadwindProtocolTests: XCTestCase { ) { XCTAssertEqual($0 as? HeadwindProtocolError, .unknownMode(0xFF)) } + XCTAssertThrowsError( + try HeadwindMessageDecoder.decode(Data([0xFE, 0x04, 0x01, 0xFF])) + ) { + XCTAssertEqual($0 as? HeadwindProtocolError, .unknownMode(0xFF)) + } + XCTAssertThrowsError( + try HeadwindMessageDecoder.decode(Data([0x00, 0x00, 0x00, 0x00])) + ) { + XCTAssertEqual($0 as? HeadwindProtocolError, .malformedMessage) + } } } diff --git a/Tests/VirtualGearsCoreTests/ProductPresentationTests.swift b/Tests/VirtualGearsCoreTests/ProductPresentationTests.swift new file mode 100644 index 0000000..d751f35 --- /dev/null +++ b/Tests/VirtualGearsCoreTests/ProductPresentationTests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import VirtualGearsCore + +final class ProductPresentationTests: XCTestCase { + func testEveryConnectionStateHasPlainLanguageLabels() { + let states: [(ProductConnectionState, String, String)] = [ + (.unavailable("Bluetooth is off"), "Bluetooth is off", "Bluetooth is off"), + (.disconnected, "Not connected", "Not connected"), + (.scanning, "Looking for it…", "Looking for it…"), + (.reconnecting(attempt: 3), "Reconnecting…", "Reconnecting…"), + (.connecting(name: "KICKR"), "Connecting to KICKR…", "Connecting…"), + (.discovering, "Getting ready…", "Getting ready…"), + (.preparing, "Almost ready…", "Almost ready…"), + (.ready, "Ready", "Ready"), + (.disconnecting, "Disconnecting…", "Disconnecting…"), + (.failed("Control denied"), "Control denied", "Control denied"), + ] + + for (state, label, shortLabel) in states { + XCTAssertEqual(state.label, label) + XCTAssertEqual(state.shortLabel, shortLabel) + } + } + + func testEveryEquipmentDisplayStateHasALabel() { + XCTAssertEqual(EquipmentDisplayState.connected.label, "Connected") + XCTAssertEqual(EquipmentDisplayState.connecting.label, "Connecting") + XCTAssertEqual(EquipmentDisplayState.disconnected.label, "Not connected") + XCTAssertEqual(EquipmentDisplayState.notAdded.label, "Not added") + } + + func testDiscoveryCanReturnToSearchingAndReset() { + var discovery = DeviceDiscoveryState() + discovery.start() + discovery.observe(candidateCount: 1) + XCTAssertEqual(discovery.phase, .showingResults) + + discovery.observe(candidateCount: 0) + XCTAssertEqual(discovery.phase, .searching) + + discovery.reset() + XCTAssertEqual(discovery.phase, .idle) + } + + func testTrainerSafetyRoundsToTheCommandResolution() { + XCTAssertEqual(TrainerSafety.circumferenceAsSent(2_070.04), 2_070.0) + XCTAssertEqual(TrainerSafety.circumferenceAsSent(2_070.06), 2_070.1) + XCTAssertEqual( + TrainerSafety.widestSupportedSpan, + TrainerSafety.supportedScaleRange.upperBound + / TrainerSafety.supportedScaleRange.lowerBound + ) + } + + func testKickrCapabilitiesAndPeripheralSuccessKeepTheirValues() throws { + let feature = FitnessMachineFeature( + machineFeatures: [.cadence], + targetSettingFeatures: [] + ) + let resistance = try SupportedResistanceLevelRange( + minimumTenths: -10, + maximumTenths: 100, + incrementTenths: 1 + ) + let capabilities = KickrCapabilities( + feature: feature, + resistanceRange: resistance, + supportsWahooControl: true + ) + + XCTAssertEqual(capabilities.feature, feature) + XCTAssertEqual(capabilities.resistanceRange, resistance) + XCTAssertTrue(capabilities.supportsWahooControl) + + let result = FTMSPeripheralCommandResult.success(status: .startedOrResumed) + XCTAssertEqual(result.result, .success) + XCTAssertEqual(result.status, .startedOrResumed) + } +} diff --git a/Tests/VirtualGearsCoreTests/ProxyCoordinatorTests.swift b/Tests/VirtualGearsCoreTests/ProxyCoordinatorTests.swift index 802c8cf..5fb4093 100644 --- a/Tests/VirtualGearsCoreTests/ProxyCoordinatorTests.swift +++ b/Tests/VirtualGearsCoreTests/ProxyCoordinatorTests.swift @@ -80,6 +80,17 @@ final class ProxyCoordinatorTests: XCTestCase { XCTFail("Timed out waiting for the ride to reach the expected state") } + func testTrainerProxyCanBeAvailableBeforeShiftingStarts() async { + coordinator.makeTrainerProxyAvailable() + + XCTAssertTrue(ridingApp.isAdvertising) + XCTAssertEqual(coordinator.state, .idle) + XCTAssertNil(coordinator.displayedGear) + + let response = await ridingApp.send(.requestControl) + XCTAssertEqual(response?.result, .success) + } + // MARK: - What the trainer is left on func testStoppingPutsTheTrainerBackOnTheSizeItStartedWith() async throws { diff --git a/Tests/VirtualGearsCoreTests/RideReadoutPresentationTests.swift b/Tests/VirtualGearsCoreTests/RideReadoutPresentationTests.swift new file mode 100644 index 0000000..7e02c38 --- /dev/null +++ b/Tests/VirtualGearsCoreTests/RideReadoutPresentationTests.swift @@ -0,0 +1,104 @@ +import XCTest +@testable import VirtualGearsCore + +/// The gear read-out is the only thing on the screen a rider looks at while +/// pedalling, so how it is drawn is a decision worth pinning down rather than +/// leaving to whichever font size someone last typed into the view. +final class RideReadoutPresentationTests: XCTestCase { + func testTheRailFillsEveryGearBehindTheConfirmedOne() { + let markers = GearRail.markers(count: 8, selected: 3, requested: nil) + + XCTAssertEqual( + markers, + [.behind, .behind, .behind, .selected, .ahead, .ahead, .ahead, .ahead] + ) + } + + func testTheRailMarksTheRequestedGearUntilTheTrainerConfirmsIt() { + let harder = GearRail.markers(count: 6, selected: 2, requested: 4) + XCTAssertEqual( + harder, + [.behind, .behind, .selected, .ahead, .requested, .ahead] + ) + + let easier = GearRail.markers(count: 6, selected: 4, requested: 1) + XCTAssertEqual( + easier, + [.behind, .requested, .behind, .behind, .selected, .ahead] + ) + } + + func testAConfirmedRequestIsDrawnOnlyAsTheSelectedGear() { + let markers = GearRail.markers(count: 4, selected: 2, requested: 2) + + XCTAssertEqual(markers, [.behind, .behind, .selected, .ahead]) + XCTAssertFalse(markers.contains(.requested)) + } + + func testAnUnconfirmedRailClaimsNoPosition() { + let markers = GearRail.markers(count: 3, selected: nil, requested: nil) + + XCTAssertEqual(markers, [.ahead, .ahead, .ahead]) + XCTAssertFalse(markers.contains(.selected)) + XCTAssertFalse(markers.contains(.behind)) + } + + func testTheRailIgnoresPositionsThatDoNotExist() { + XCTAssertTrue(GearRail.markers(count: 0, selected: 0, requested: 0).isEmpty) + XCTAssertTrue(GearRail.markers(count: -4, selected: nil, requested: nil).isEmpty) + XCTAssertEqual( + GearRail.markers(count: 3, selected: 9, requested: -2), + [.ahead, .ahead, .ahead] + ) + } + + func testEveryMarkerKindIsDistinguishableWithoutColour() { + // Colour alone is not readable at a glance on a bike, and is not + // readable at all to a rider who cannot tell these colours apart. + let heights = Set(GearRailMarker.allCases.map(\.relativeHeight)) + XCTAssertEqual(heights.count, GearRailMarker.allCases.count) + XCTAssertEqual(GearRailMarker.selected.relativeHeight, 1) + for marker in GearRailMarker.allCases where marker != .selected { + XCTAssertLessThan(marker.relativeHeight, 1) + XCTAssertGreaterThan(marker.relativeHeight, 0) + } + } + + func testTheSecondLineStaysSubordinateToTheGearNumber() { + // The gear position owns the screen. Everything else on the read-out is + // a caption to it, whatever size the gear itself ends up being — unless + // being subordinate would take it below the size it can still be read + // at, in which case being readable wins. + for primary in stride(from: 44.0, through: 320.0, by: 4.0) { + let secondary = GearReadoutMetrics.secondaryPointSize(forPrimary: primary) + XCTAssertGreaterThanOrEqual(secondary, 15) + XCTAssertLessThanOrEqual(secondary, 24) + XCTAssertTrue( + secondary <= primary * 0.3 || secondary == 15, + "\(secondary)pt is too loud next to a \(primary)pt gear" + ) + } + } + + func testTheSecondLineNeverShrinksAsTheGearGrows() { + let sizes = stride(from: 44.0, through: 320.0, by: 8.0) + .map(GearReadoutMetrics.secondaryPointSize(forPrimary:)) + + XCTAssertEqual(sizes, sizes.sorted()) + } + + func testCassettesOfTheSameSpreadAreToldApartByTheirCogCount() { + // Three different cassettes are all called "11-28". Saying only that + // leaves a rider unable to tell which one they picked. + let repeated = Dictionary(grouping: DrivetrainCatalog.cassettes, by: \.name) + .filter { $0.value.count > 1 } + XCTAssertFalse(repeated.isEmpty, "The catalogue no longer repeats a name") + + for option in DrivetrainCatalog.cassettes { + XCTAssertEqual(option.qualifiedName, "\(option.name) · \(option.speeds) cogs") + } + + let qualified = Set(DrivetrainCatalog.cassettes.map(\.qualifiedName)) + XCTAssertEqual(qualified.count, DrivetrainCatalog.cassettes.count) + } +} diff --git a/VirtualGears.xcodeproj/project.pbxproj b/VirtualGears.xcodeproj/project.pbxproj index 847f320..228ff4c 100644 --- a/VirtualGears.xcodeproj/project.pbxproj +++ b/VirtualGears.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + B00000000000000000000001 /* VirtualGearsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000001 /* VirtualGearsUITests.swift */; }; 100000000000000000000008 /* VirtualGearsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000009 /* VirtualGearsApp.swift */; }; 100000000000000000000009 /* VirtualGearsHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000A /* VirtualGearsHomeView.swift */; }; A00000000000000000000001 /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000001 /* AppConfiguration.swift */; }; @@ -22,6 +23,8 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + B10000000000000000000001 /* VirtualGearsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualGearsUITests.swift; sourceTree = ""; }; + B10000000000000000000002 /* VirtualGearsUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VirtualGearsUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 200000000000000000000005 /* VirtualGears.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VirtualGears.app; sourceTree = BUILT_PRODUCTS_DIR; }; 200000000000000000000009 /* VirtualGearsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualGearsApp.swift; sourceTree = ""; }; 20000000000000000000000A /* VirtualGearsHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualGearsHomeView.swift; sourceTree = ""; }; @@ -38,6 +41,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + B30000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 300000000000000000000004 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -53,6 +63,7 @@ isa = PBXGroup; children = ( 400000000000000000000004 /* VirtualGears */, + B40000000000000000000001 /* VirtualGearsUITests */, 400000000000000000000003 /* Products */, ); sourceTree = ""; @@ -80,13 +91,52 @@ isa = PBXGroup; children = ( 200000000000000000000005 /* VirtualGears.app */, + B10000000000000000000002 /* VirtualGearsUITests.xctest */, ); name = Products; sourceTree = ""; }; + B40000000000000000000001 /* VirtualGearsUITests */ = { + isa = PBXGroup; + children = ( + B10000000000000000000001 /* VirtualGearsUITests.swift */, + ); + path = VirtualGearsUITests; + sourceTree = ""; + }; /* End PBXGroup section */ +/* Begin PBXContainerItemProxy section */ + B20000000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 600000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 500000000000000000000001; + remoteInfo = VirtualGears; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXNativeTarget section */ + B50000000000000000000001 /* VirtualGearsUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = B80000000000000000000001 /* Build configuration list for PBXNativeTarget "VirtualGearsUITests" */; + buildPhases = ( + B30000000000000000000002 /* Sources */, + B30000000000000000000001 /* Frameworks */, + B30000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + B20000000000000000000002 /* PBXTargetDependency */, + ); + name = VirtualGearsUITests; + packageProductDependencies = ( + ); + productName = VirtualGearsUITests; + productReference = B10000000000000000000002 /* VirtualGearsUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; 500000000000000000000001 /* VirtualGears */ = { isa = PBXNativeTarget; buildConfigurationList = 800000000000000000000002 /* Build configuration list for PBXNativeTarget "VirtualGears" */; @@ -120,6 +170,10 @@ 500000000000000000000001 = { CreatedOnToolsVersion = 26.4; }; + B50000000000000000000001 = { + CreatedOnToolsVersion = 26.4; + TestTargetID = 500000000000000000000001; + }; }; }; buildConfigurationList = 800000000000000000000001 /* Build configuration list for PBXProject "VirtualGears" */; @@ -139,11 +193,19 @@ projectRoot = ""; targets = ( 500000000000000000000001 /* VirtualGears */, + B50000000000000000000001 /* VirtualGearsUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + B30000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 300000000000000000000006 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -156,6 +218,14 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + B30000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B00000000000000000000001 /* VirtualGearsUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 300000000000000000000005 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -175,6 +245,38 @@ /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ + B70000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = MNW6SJT4V7; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sbroenne.VirtualGearsUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_TARGET_NAME = VirtualGears; + }; + name = Debug; + }; + B70000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = MNW6SJT4V7; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sbroenne.VirtualGearsUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_TARGET_NAME = VirtualGears; + }; + name = Release; + }; 700000000000000000000001 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -214,7 +316,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = MNW6SJT4V7; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = VirtualGearsProduct/Info.plist; @@ -239,7 +341,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = MNW6SJT4V7; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = VirtualGearsProduct/Info.plist; @@ -262,6 +364,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + B80000000000000000000001 /* Build configuration list for PBXNativeTarget "VirtualGearsUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B70000000000000000000001 /* Debug */, + B70000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 800000000000000000000001 /* Build configuration list for PBXProject "VirtualGears" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -282,6 +393,14 @@ }; /* End XCConfigurationList section */ +/* Begin PBXTargetDependency section */ + B20000000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 500000000000000000000001 /* VirtualGears */; + targetProxy = B20000000000000000000001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCLocalSwiftPackageReference section */ 900000000000000000000002 /* XCLocalSwiftPackageReference "." */ = { isa = XCLocalSwiftPackageReference; diff --git a/VirtualGearsProduct/ClickCentralService.swift b/VirtualGearsProduct/ClickCentralService.swift index b2cee94..0b5a5af 100644 --- a/VirtualGearsProduct/ClickCentralService.swift +++ b/VirtualGearsProduct/ClickCentralService.swift @@ -28,6 +28,10 @@ final class ClickCentralService: NSObject { private(set) var latestButtonEvent: ZwiftClickButtonEvent? private(set) var identificationCandidateID: UUID? private(set) var latestShiftRequest: ShiftRequest? + /// The physical button whose press the app accepted. Unlike the raw button + /// event, this excludes simultaneous or overlapping presses that shifting + /// deliberately ignores. + private(set) var pressedButton: ZwiftClickButton? private(set) var shiftRequests: [ShiftRequest] = [] var shiftHandler: ((ShiftRequest) -> Void)? @@ -430,6 +434,7 @@ final class ClickCentralService: NSObject { return } heldButton = button + pressedButton = button emit(.single(direction(for: button))) startRepeat(button) case let .released(button): @@ -460,6 +465,7 @@ final class ClickCentralService: NSObject { repeatTask?.cancel() repeatTask = nil heldButton = nil + pressedButton = nil if isHolding { isHolding = false emit(.holdEnded) @@ -536,6 +542,10 @@ extension ClickCentralService { self.batteryLevel = batteryLevel state = .ready } + + func stageScreenshotPressedButton(_ button: ZwiftClickButton) { + pressedButton = button + } } #endif diff --git a/VirtualGearsProduct/FTMSPeripheral.swift b/VirtualGearsProduct/FTMSPeripheral.swift index 4e3401e..7897c12 100644 --- a/VirtualGearsProduct/FTMSPeripheral.swift +++ b/VirtualGearsProduct/FTMSPeripheral.swift @@ -401,6 +401,12 @@ final class FTMSPeripheral: NSObject { #if DEBUG extension FTMSPeripheral { + func stageScreenshotAdvertising() { + isAdvertising = true + subscribedAppCount = 0 + controllingAppID = nil + } + func stageScreenshotConnection() { isAdvertising = true subscribedAppCount = 1 diff --git a/VirtualGearsProduct/SetupView.swift b/VirtualGearsProduct/SetupView.swift index 8aa8f53..4d4c500 100644 --- a/VirtualGearsProduct/SetupView.swift +++ b/VirtualGearsProduct/SetupView.swift @@ -21,6 +21,7 @@ struct SetupView: View { } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("screen.settings") .task { if autoConnectsOnAppear { autoConnectSavedEquipment() @@ -109,10 +110,18 @@ struct SetupView: View { } } - private var shiftingValue: String { + /// The saved name, unless it is the row's own title. A Click that reports + /// itself as "Zwift Click" under a row called "Zwift Click" says the same + /// word twice and tells the rider nothing; the badge beside it already says + /// whether it is there. + private var shiftingValue: String? { let name = store.configuration.clickName .trimmingCharacters(in: .whitespacesAndNewlines) - return name.isEmpty ? "Not added" : name + if name.isEmpty { return "Not added" } + guard name.caseInsensitiveCompare("Zwift Click") != .orderedSame else { + return nil + } + return name } private var gearsSection: some View { @@ -120,11 +129,14 @@ struct SetupView: View { NavigationLink { GearChoiceView(store: store) } label: { + // The row leads with what the rider chose, not with a count of + // it. "24 gears · extra-low climbing range" under the word Gears + // describes the result of a decision without ever naming it. LabeledContent { - Text(store.configuration.drivetrainName) + Text(store.configuration.gearSummary) } label: { Text("Gears") - Text(store.configuration.gearSummary) + Text(store.configuration.drivetrainName) } } @@ -458,6 +470,7 @@ struct HeadwindControlView: View { } .navigationTitle("Headwind") .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("screen.headwind") .toolbar { if let onDone { ToolbarItem(placement: .confirmationAction) { @@ -669,9 +682,9 @@ struct GearChoiceView: View { } footer: { Text( store.configuration.usesVirtualGears - ? "Twenty-four very wide virtual gears, from an easier " - + "climbing gear to the same hard end. They belong " - + "to no particular bike." + ? "Twenty-four evenly spaced gears with an extra-low " + + "climbing range. They are designed for indoor " + + "riding rather than copied from a particular bike." : "Copy the numbers printed on your own bike, or pick " + "any combination you would like to ride. It does " + "not have to be a set that anyone sells." @@ -694,7 +707,7 @@ struct GearChoiceView: View { } label: { LabeledContent( "Cassette", - value: store.configuration.cassette.name + value: store.configuration.cassette.qualifiedName ) } } header: { @@ -710,6 +723,7 @@ struct GearChoiceView: View { } .navigationTitle("Gears") .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("screen.gears") } } @@ -895,6 +909,7 @@ private struct CassetteChoiceView: View { ForEach(options(speeds: speeds)) { option in ChoiceRow( title: option.name, + spokenTitle: option.qualifiedName, note: option.note, detail: option.cogs.map(String.init) .joined(separator: ", "), @@ -933,6 +948,10 @@ private struct CassetteChoiceView: View { /// dimmed and says why, rather than disappearing and leaving the rider guessing. private struct ChoiceRow: View { let title: String + /// What VoiceOver says, when the visible title alone is ambiguous. Three + /// cassettes are called "11-28"; on screen their section heading tells them + /// apart, but a rider hearing the list gets no heading with each row. + var spokenTitle: String? let note: String let detail: String? let selected: Bool @@ -969,8 +988,8 @@ private struct ChoiceRow: View { .opacity(fits ? 1 : 0.5) .accessibilityLabel( fits - ? "\(title), \(note)" - : "\(title), too wide to combine with your other choice" + ? "\(spokenTitle ?? title), \(note)" + : "\(spokenTitle ?? title), too wide to combine with your other choice" ) .accessibilityAddTraits(selected ? .isSelected : []) } diff --git a/VirtualGearsProduct/VirtualGearsApp.swift b/VirtualGearsProduct/VirtualGearsApp.swift index 1741cb4..f6abc6a 100644 --- a/VirtualGearsProduct/VirtualGearsApp.swift +++ b/VirtualGearsProduct/VirtualGearsApp.swift @@ -94,7 +94,15 @@ struct VirtualGearsApp: App { #if DEBUG enum ScreenshotFixture: String { case starting = "-shotStarting" + case ready = "-shotReady" + case failed = "-shotFailed" case ride = "-shotRide" + case rideAccessibility = "-shotRideAccessibility" + case rideWaiting = "-shotRideWaiting" + case rideLowBattery = "-shotRideLowBattery" + case ridePending = "-shotRidePending" + case ridePressed = "-shotRidePressed" + case rideReconnecting = "-shotRideReconnecting" case settings = "-shotSettings" case gears = "-shotGears" case realGears = "-shotRealGears" @@ -129,17 +137,17 @@ private struct ScreenshotFixtureView: View { var body: some View { Group { switch scenario { - case .starting: + case .starting, .ready, .failed: StartupView( store: store, kickr: kickr, click: click, headwind: headwind, coordinator: coordinator, - autoStarts: true, beginsDiscovery: false ) - case .ride: + case .ride, .rideAccessibility, .rideWaiting, .rideLowBattery, + .ridePending, .ridePressed, .rideReconnecting: ShiftingView( store: store, kickr: kickr, @@ -170,6 +178,9 @@ private struct ScreenshotFixtureView: View { DemoModeView(onExit: {}) } } + .dynamicTypeSize( + scenario == .rideAccessibility ? .accessibility5 : .large + ) .task { stage() try? await Task.sleep(for: .milliseconds(500)) @@ -206,11 +217,43 @@ private struct ScreenshotFixtureView: View { speed: 50 ) - if scenario == .ride { + if scenario == .rideLowBattery { + click.stageScreenshot(name: configuration.clickName, batteryLevel: 15) + } else if scenario == .ridePressed { + click.stageScreenshotPressedButton(.plus) + } + + if scenario == .ready || scenario == .rideWaiting { + (coordinator.peripheral as? FTMSPeripheral)? + .stageScreenshotAdvertising() + } else if isRideScenario { (coordinator.peripheral as? FTMSPeripheral)? .stageScreenshotConnection() + } + if isRideScenario { coordinator.stageScreenshotRide(configuration: configuration) } + if scenario == .failed { + coordinator.stageScreenshotFailure("KICKR denied FTMS control") + } else if scenario == .ridePending { + coordinator.stageScreenshotPendingShift() + } else if scenario == .rideReconnecting { + kickr.stageScreenshot( + name: configuration.kickrName, + state: .connecting(name: configuration.kickrName) + ) + coordinator.stageScreenshotReconnecting() + } + } + + private var isRideScenario: Bool { + switch scenario { + case .ride, .rideAccessibility, .rideWaiting, .rideLowBattery, + .ridePending, .ridePressed, .rideReconnecting: + true + default: + false + } } } #endif diff --git a/VirtualGearsProduct/VirtualGearsHomeView.swift b/VirtualGearsProduct/VirtualGearsHomeView.swift index bbb70b9..e64645b 100644 --- a/VirtualGearsProduct/VirtualGearsHomeView.swift +++ b/VirtualGearsProduct/VirtualGearsHomeView.swift @@ -9,9 +9,6 @@ struct VirtualGearsHomeView: View { @Bindable var headwind: HeadwindCentralService @Bindable var coordinator: ProxyCoordinator @Binding var isDemoMode: Bool - /// Set once the rider stops a ride, so the app does not immediately start a - /// new one. Reopening the app is the only way to ask for another ride. - @State private var riderStopped = false /// Whether demo entry took the trainer service away from a still-connected /// riding app, so exit knows to put it back. @State private var demoInterruptedAdvertising = false @@ -29,7 +26,7 @@ struct VirtualGearsHomeView: View { click: click, headwind: headwind, coordinator: coordinator, - onRiderStop: { riderStopped = true } + onRiderStop: {} ) } else { StartupView( @@ -38,7 +35,6 @@ struct VirtualGearsHomeView: View { click: click, headwind: headwind, coordinator: coordinator, - autoStarts: !riderStopped, onTryDemo: enterDemoMode ) } @@ -148,8 +144,6 @@ struct StartupView: View { @Bindable var click: ClickCentralService @Bindable var headwind: HeadwindCentralService @Bindable var coordinator: ProxyCoordinator - /// False after the rider stops a ride, so this screen waits for a tap. - var autoStarts: Bool = true var beginsDiscovery = true var onTryDemo: () -> Void = {} @State private var showsSettings = false @@ -167,12 +161,19 @@ struct StartupView: View { retryButton } else if mustChoose { chooser - } else if autoStarts { - searching + } else if canStart { + readyCard + retryButton } else { - stoppedCard + searching retryButton } + // Fixed in the layout regardless of state, so it never + // appears or disappears under the button above it. It used + // to live only inside the searching and chooser cards, so + // the button jumped the instant the trainer connected and + // this reminder vanished with the rest of that card. + chainReminder demoEntry } .frame(maxWidth: 560) @@ -205,12 +206,13 @@ struct StartupView: View { await begin() } } - .onChange(of: canStart) { _, _ in startIfReady() } + .onChange(of: canStart) { _, _ in makeProxyAvailableIfReady() } .onChange(of: kickr.candidates) { _, _ in considerCandidates() } .onDisappear { kickr.stopScanning(reconnectSavedDevice: false) } } + .accessibilityIdentifier("screen.startup") } // MARK: - Finding a trainer @@ -218,7 +220,7 @@ struct StartupView: View { private func begin() async { if store.configuration.hasValidKickr { kickr.autoConnectSavedDevice() - startIfReady() + makeProxyAvailableIfReady() } else { trainerScanSettled = false kickr.startScanning() @@ -229,12 +231,12 @@ struct StartupView: View { guard !Task.isCancelled else { return } trainerScanSettled = true considerCandidates() - startIfReady() + makeProxyAvailableIfReady() } /// Never interrupts a connection already under way. private func considerCandidates() { - guard trainerScanSettled, autoStarts, + guard trainerScanSettled, !store.configuration.hasValidKickr, kickr.selectedID == nil, !mustChoose else { return } let seen = kickr.candidates.map { DiscoveredTrainer(id: $0.id) } @@ -264,14 +266,13 @@ struct StartupView: View { .font(.title3.weight(.semibold)) .multilineTextAlignment(.center) Text( - "Turn the pedals if your trainer is asleep. Virtual shifting starts " - + "by itself, and your riding app will find Virtual Gears." + "Turn the pedals if your trainer is asleep. Your riding app can " + + "find Virtual Gears as soon as the trainer is connected." ) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) connectionList(includeRidingApp: false) - chainReminder } .frame(maxWidth: .infinity) .accessibilityElement(children: .contain) @@ -304,7 +305,6 @@ struct StartupView: View { } .buttonStyle(.bordered) } - chainReminder } .frame(maxWidth: .infinity, alignment: .leading) } @@ -334,8 +334,8 @@ struct StartupView: View { "Opens a simulated ride without connecting to a trainer" ) Text( - "No trainer nearby? Watch a simulated ride show how shifting " - + "resizes the trainer's wheel. Demo Mode does not use " + "No trainer nearby? Try the same large shift buttons and gear " + + "display used during a real ride. Demo Mode does not use " + "Bluetooth." ) .font(.footnote) @@ -347,11 +347,11 @@ struct StartupView: View { // MARK: - Stopping and failing - private var stoppedCard: some View { + private var readyCard: some View { VStack(spacing: 16) { - Text("Virtual shifting stopped") + Text("Ready to shift") .font(.title3.weight(.semibold)) - connectionList(includeRidingApp: false) + connectionList(includeRidingApp: true) } .frame(maxWidth: .infinity) } @@ -362,7 +362,6 @@ struct StartupView: View { id: "trainer", name: store.configuration.kickrName.isEmpty ? "Trainer" : store.configuration.kickrName, - role: "Trainer", detail: connectionDetail(kickr.state), state: connectionState(kickr.state, isReady: kickr.isReady) ) @@ -372,7 +371,6 @@ struct StartupView: View { ConnectionStatusItem( id: "click", name: store.configuration.clickName, - role: "Zwift Click", detail: connectionDetail(click.state), state: connectionState( click.state, @@ -387,7 +385,6 @@ struct StartupView: View { ConnectionStatusItem( id: "headwind", name: store.configuration.headwindName ?? "Wahoo HEADWIND", - role: "Fan", detail: connectionDetail(headwind.state), state: connectionState( headwind.state, @@ -403,8 +400,7 @@ struct StartupView: View { items.append( ConnectionStatusItem( id: "riding-app", - name: "PC riding app", - role: "Riding app", + name: "Riding app", detail: steering ? "Connected and steering" : (connected ? "Connected" : "Waiting for connection"), @@ -478,12 +474,20 @@ struct StartupView: View { } } + /// The same control starts shifting and tries again after a failure. After a + /// failure "Start Shifting" reads as though nothing had been attempted, + /// which is exactly the doubt the card above it has just resolved. private var retryButton: some View { Button { headwind.applySavedControlPreference() coordinator.startShifting(configuration: store.configuration) } label: { - Label("Start Shifting", systemImage: "bicycle") + Label( + retryTitle, + systemImage: canStart + ? (failureMessage == nil ? "bicycle" : "arrow.clockwise") + : "hourglass" + ) .font(.title2.bold()) .frame(maxWidth: .infinity, minHeight: 64) } @@ -495,6 +499,11 @@ struct StartupView: View { ) } + private var retryTitle: String { + guard canStart else { return "Waiting for trainer" } + return failureMessage == nil ? "Start Shifting" : "Try Again" + } + // MARK: - Starting /// Readiness means actually connected, not merely remembered. @@ -509,13 +518,11 @@ struct StartupView: View { return nil } - /// The app does only one thing, so opening it is the instruction. - private func startIfReady() { - guard autoStarts, canStart, coordinator.state == .idle else { return } + private func makeProxyAvailableIfReady() { + guard canStart else { return } kickr.stopScanning() mustChoose = false - headwind.applySavedControlPreference() - coordinator.startShifting(configuration: store.configuration) + coordinator.makeTrainerProxyAvailable() } } @@ -612,6 +619,7 @@ struct DemoModeView: View { .task { await runSimulatedTrainer() } .task { await runSimulatedRidingApp() } .onDisappear(perform: stopSweep) + .accessibilityIdentifier("screen.demo") } private var simulationNotice: some View { @@ -619,11 +627,16 @@ struct DemoModeView: View { Label("Demo Mode · Simulated", systemImage: "testtube.2") .font(.title3.weight(.bold)) Text( - "No trainer is connected. This demo stays on your iPhone and " - + "does not use Bluetooth. The wheel sizes and commands " - + "below are the real ones a shift would send." + "Try the same large shift buttons and gear display used during " + + "a real ride." ) .font(.subheadline) + Text( + "No trainer is connected and Bluetooth stays off. The details " + + "below explain what a confirmed shift changes." + ) + .font(.footnote) + .foregroundStyle(.secondary) } .padding() .frame(maxWidth: .infinity, alignment: .leading) @@ -737,7 +750,8 @@ struct DemoModeView: View { hint: easier ? "Moves to the next easier simulated gear. Hold to keep shifting." : "Moves to the next harder simulated gear. Hold to keep shifting.", - disabled: easier ? !ride.canShiftEasier : !ride.canShiftHarder + disabled: easier ? !ride.canShiftEasier : !ride.canShiftHarder, + isProminent: !easier ) { ride.shift(direction) } repeatAction: { @@ -958,6 +972,7 @@ private struct DemoSettingsView: View { .fontWeight(.semibold) } } + .accessibilityIdentifier("screen.demo-settings") } } @@ -1022,6 +1037,7 @@ private struct DemoHeadwindControlView: View { .fontWeight(.semibold) } } + .accessibilityIdentifier("screen.demo-headwind") } } @@ -1145,15 +1161,13 @@ struct ShiftingView: View { .accessibilityLabel("Headwind controls") } } - // The middle of the bar says what the ride is doing whenever it - // is doing anything other than simply running. + // What the ride is doing is said in words next to the gear, not + // here. The middle of the bar is squeezed between the controls + // on either side, and a status put here loses its text long + // before it loses its icon, leaving a bare warning glyph with + // nothing to explain it. ToolbarItem(placement: .principal) { - if coordinator.state != .active { - Label(statusText, systemImage: statusSymbol) - .font(.footnote.weight(.semibold)) - .foregroundStyle(statusColor) - .lineLimit(1) - } else { + if coordinator.state == .active && !isChangingGears { gearsMenu } } @@ -1225,18 +1239,23 @@ struct ShiftingView: View { performFeedback(coordinator.lastShiftFeedback) announce("Gear \(gearAccessibilityValue)") } - .confirmationDialog( - "Stop virtual shifting?", - isPresented: $confirmsStop, - titleVisibility: .visible - ) { + // An alert rather than a confirmation dialog, because the dialog renders + // as a compact card that drops its cancel button entirely, leaving a + // rider mid-ride with a destructive choice and no visible way out. + .alert("Stop virtual shifting?", isPresented: $confirmsStop) { + Button("Cancel", role: .cancel) {} Button("Stop Shifting", role: .destructive) { onRiderStop() headwind.releaseFanControl() Task { await coordinator.stopShifting() } } - Button("Cancel", role: .cancel) {} + } message: { + Text( + "The trainer goes back to its own gearing. " + + "Your riding app stays connected." + ) } + .accessibilityIdentifier("screen.ride") } /// The gear is the one thing the rider looks at, so it owns the screen and @@ -1260,7 +1279,10 @@ struct ShiftingView: View { } } label: { HStack(spacing: 4) { - Text(configuration.drivetrainName) + Text( + dynamicTypeSize.isAccessibilitySize + ? "Gears" : configuration.drivetrainName + ) .font(.headline) Image(systemName: "chevron.down") .font(.caption2.weight(.bold)) @@ -1330,12 +1352,45 @@ struct ShiftingView: View { } private func gearHero(fontSize: CGFloat) -> some View { - gearReadout( - fontSize: fontSize - ) + VStack(spacing: 12) { + if showsRideStatus { + rideStatus + } + gearReadout( + fontSize: fontSize + ) + } .frame(maxWidth: .infinity, maxHeight: .infinity) } + /// True whenever the ride is doing something other than simply running, and + /// so has something to say to the rider. + private var showsRideStatus: Bool { + coordinator.state != .active || isChangingGears + } + + /// Says what the ride is doing, in words, at a size that survives being read + /// from a bike. The icon carries no meaning on its own, so it is hidden from + /// VoiceOver and never allowed to appear without its text. + private var rideStatus: some View { + HStack(spacing: 8) { + Image(systemName: statusSymbol) + .font(.title3.weight(.semibold)) + .accessibilityHidden(true) + Text(statusText) + .font(.title3.weight(.semibold)) + .multilineTextAlignment(.leading) + .lineLimit(3) + .minimumScaleFactor(0.7) + } + .foregroundStyle(statusColor) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(statusColor.opacity(0.14), in: .rect(cornerRadius: 16)) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("ride.status") + } + private var hasEquipmentProblem: Bool { equipmentItems.contains { !$0.isOptional && $0.state != .ok } } @@ -1348,78 +1403,128 @@ struct ShiftingView: View { return "\(problem.title), \(problem.detail)" } - /// Supporting detail, so it sits at the bottom in the quietest type on the - /// screen and never takes more than one line. When something is wrong that - /// single line becomes the plain-English problem instead, so the rider only - /// ever reads one thing down here. + /// Every device keeps its own status element. Accessibility sizes use two + /// columns rather than breaking short equipment names in the middle. private var equipmentFooter: some View { Group { if let problem = equipmentItems.first(where: { !$0.isOptional && $0.state != .ok }) { - Label( - "\(problem.title) · \(problem.detail)", - systemImage: problem.state.symbol + HStack(spacing: 6) { + Image(systemName: problem.state.symbol) + .foregroundStyle(problem.state.tint) + Text("\(problem.title) · \(problem.detail)") + } + .font(.body.weight(.semibold)) + .lineLimit(2) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + Color(.secondarySystemGroupedBackground), + in: Capsule() ) - .foregroundStyle(problem.state.tint) .accessibilityElement(children: .ignore) .accessibilityLabel(equipmentProblem ?? "") + .accessibilityIdentifier("status.\(problem.id)") } else { - // The KICKR and the Click are grouped because Virtual Gears is - // the one connecting to them. The riding app is set apart - // because it connects to Virtual Gears instead. - HStack(spacing: 26) { - equipmentGroup(items: ownedEquipment.filter { $0.state == .ok }) - equipmentGroup(items: [ridingAppEquipment]) - // Deliberately separate from the Click's tick, which means - // connected. Tinting that tick would read, at a glance on - // a moving bike, as the Click having dropped out. - if configuration.usesClick, click.isReady, click.batteryIsLow, - let battery = click.batteryLevel { - HStack(spacing: 4) { - Image(systemName: "battery.25percent") - .foregroundStyle(.orange) - Text("Click \(battery)%") + VStack(spacing: 6) { + if dynamicTypeSize.isAccessibilitySize { + Grid(horizontalSpacing: 6, verticalSpacing: 6) { + ForEach( + Array(statusRows.enumerated()), + id: \.offset + ) { _, row in + GridRow { + ForEach(row) { item in + equipmentStatus(item, expands: true) + } + } + } + } + } else { + // One row for everything. Splitting three across the top + // and one underneath left the riding app looking like a + // different kind of thing to the equipment above it. + HStack(spacing: 10) { + ForEach(equipmentItems) { item in + equipmentStatus(item) + } } - .accessibilityElement(children: .ignore) - .accessibilityLabel("Click battery low, \(battery) percent") + equipmentNotes } - if coordinator.ridingAppSetWheelSize { - Text("Wheel size from your app") - .accessibilityLabel( - "Your riding app set the wheel size. " - + "Your gears are built around it." - ) + if dynamicTypeSize.isAccessibilitySize { + equipmentNotes } } - .foregroundStyle(.secondary) } } - .font(.caption) - // The one line a rider most needs when something is wrong is the line - // that must not be squeezed away. Large text sizes are chosen by people - // who need them, so it wraps rather than shrinking to nothing. - .lineLimit(2) - .minimumScaleFactor(0.8) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .accessibilityElement(children: .contain) } - /// Every item carries its own tick. Sharing one tick across a group read as - /// though only the first piece of equipment was connected. - private func equipmentGroup(items: [EquipmentItem]) -> some View { - HStack(spacing: 10) { - ForEach(items) { item in - HStack(spacing: 4) { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text(item.title) + private var statusRows: [[EquipmentItem]] { + stride(from: 0, to: equipmentItems.count, by: 2).map { start in + Array(equipmentItems[start.. some View { + HStack(spacing: 5) { + Image(systemName: item.state.symbol) + .foregroundStyle(item.state.tint) + .accessibilityIdentifier("status.\(item.id).icon") + Text( + dynamicTypeSize.isAccessibilitySize && item.id == "ridingapp" + ? "App" : item.title + ) + .lineLimit(1) + .minimumScaleFactor(0.75) + .accessibilityIdentifier("status.\(item.id).label") + } + .font(.footnote.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.vertical, expands ? 4 : 0) + .frame(maxWidth: expands ? .infinity : nil) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(item.title), \(item.detail)") + .accessibilityIdentifier("status.\(item.id)") + } + + @ViewBuilder + private var equipmentNotes: some View { + HStack(spacing: 12) { + if configuration.usesClick, click.isReady, click.batteryIsLow, + let battery = click.batteryLevel { + // A shifter about to go flat mid-ride is not a footnote. It is + // the one thing on this screen a rider can still do something + // about, so it is drawn as the warning it is. + HStack(spacing: 5) { + Image(systemName: "battery.25percent") + Text("Click battery \(battery)%") } + .font(.footnote.weight(.semibold)) + .foregroundStyle(.orange) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.orange.opacity(0.16), in: Capsule()) .accessibilityElement(children: .ignore) - .accessibilityLabel("\(item.title), connected") + .accessibilityLabel("Click battery low, \(battery) percent") + .accessibilityIdentifier("note.clickBattery") + } + if coordinator.ridingAppSetWheelSize { + Text("Wheel size from your app") + .accessibilityLabel( + "Your riding app set the wheel size. " + + "Your gears are built around it." + ) } } + .font(.caption) + .foregroundStyle(.secondary) } private var equipmentItems: [EquipmentItem] { @@ -1434,7 +1539,8 @@ struct ShiftingView: View { title: "KICKR", state: kickr.isReady ? .ok : (kickr.state.isConnectionInProgress ? .pending : .warn), - detail: kickr.state.label + detail: coordinator.state == .reconnecting + ? "Reconnecting" : kickr.state.label ) ] if configuration.usesClick { @@ -1473,15 +1579,12 @@ struct ShiftingView: View { } else if isConnected { detail = "Connected" } else if isAdvertising { - // Virtual Gears broadcasts its own name, but iOS also reports the - // phone's name and will not let an app change it, so some riding - // apps list the phone instead. A rider hunting a name that is not - // there assumes it is broken, so name both before they look. - detail = "Pick Virtual Gears or your iPhone's name" + detail = "Waiting for connection" } else { detail = "Not advertising" } return EquipmentItem( + isOptional: true, id: "ridingapp", title: "Riding app", state: isConnected ? .ok : (isAdvertising ? .pending : .warn), @@ -1506,7 +1609,15 @@ struct ShiftingView: View { .accessibilityLabel("Gear") .accessibilityValue(gearAccessibilityValue) Text(secondaryGearText) - .font(.largeTitle.weight(.bold)) + .font( + .system( + size: GearReadoutMetrics.secondaryPointSize( + forPrimary: max(44, fontSize) + ), + weight: .semibold, + design: .rounded + ) + ) .foregroundStyle(isShiftPending ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) .lineLimit(1) .minimumScaleFactor(0.6) @@ -1532,17 +1643,21 @@ struct ShiftingView: View { } private func shiftButton(easier: Bool) -> some View { - ShiftButton( + let direction: ShiftDirection = easier ? .easier : .harder + return ShiftButton( title: easier ? "Easier" : "Harder", symbol: easier ? "minus" : "plus", hint: easier ? "Requests the next easier gear. Hold to keep shifting easier." : "Requests the next harder gear. Hold to keep shifting harder.", - disabled: easier ? !coordinator.canShiftEasier : !coordinator.canShiftHarder + disabled: easier ? !coordinator.canShiftEasier : !coordinator.canShiftHarder, + externallyPressed: click.pressedButton + == (easier ? .minus : .plus), + isProminent: !easier ) { - coordinator.shift(easier ? .easier : .harder) + coordinator.shift(direction) } repeatAction: { - coordinator.beginHold(easier ? .easier : .harder) + coordinator.beginHold(direction) } releaseAction: { coordinator.endHold() } @@ -1702,7 +1817,6 @@ private struct EquipmentItem: Identifiable { private struct ConnectionStatusItem: Identifiable { let id: String let name: String - let role: String let detail: String let state: EquipmentItem.LinkState } @@ -1722,11 +1836,12 @@ private struct ConnectionStatusList: View { .foregroundStyle(item.state.tint) .font(.title3) .frame(width: 28) + .accessibilityIdentifier("status.\(item.id).icon") VStack(alignment: .leading, spacing: 2) { Text(item.name) .font(.headline) .lineLimit(1) - Text("\(item.role) · \(item.detail)") + Text(item.detail) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(2) @@ -1736,7 +1851,8 @@ private struct ConnectionStatusList: View { .padding(.horizontal, 14) .padding(.vertical, 11) .accessibilityElement(children: .ignore) - .accessibilityLabel("\(item.name), \(item.role), \(item.detail)") + .accessibilityLabel("\(item.name), \(item.detail)") + .accessibilityIdentifier("status.\(item.id)") } } .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16)) @@ -1753,10 +1869,17 @@ private struct ShiftButton: View { let symbol: String let hint: String let disabled: Bool + var externallyPressed = false + /// Harder is drawn solid and easier is drawn light. Two identical slabs + /// differing only by a small symbol have to be read before they can be + /// used; a difference in weight can be seen without looking directly at + /// it, and survives being seen in greyscale. + var isProminent = true let action: () -> Void let repeatAction: () -> Void let releaseAction: () -> Void @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var reduceMotion @ScaledMetric(relativeTo: .largeTitle) private var symbolSize: CGFloat = 56 @State private var repeatTask: Task? @State private var isHeld = false @@ -1766,21 +1889,21 @@ private struct ShiftButton: View { @State private var suppressTapUntil: Date? var body: some View { - Button(action: tapped) { - VStack(spacing: 6) { - Image(systemName: symbol) - .font(.system(size: symbolSize, weight: .black, design: .rounded)) - .frame(height: symbolSize) - Text(title) - .font(.title2.weight(.bold)) - .lineLimit(1) - .minimumScaleFactor(0.6) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .buttonStyle(.borderedProminent) - .buttonBorderShape(.roundedRectangle(radius: 24)) + styledButton + .buttonBorderShape(.roundedRectangle(radius: 24)) .disabled(disabled) + .brightness(externallyPressed ? -0.12 : 0) + .scaleEffect(externallyPressed && !reduceMotion ? 0.96 : 1) + .overlay { + RoundedRectangle(cornerRadius: 24) + .stroke(.tint, lineWidth: externallyPressed ? 5 : 0) + .padding(2) + .allowsHitTesting(false) + } + .animation( + reduceMotion ? nil : .easeOut(duration: 0.08), + value: externallyPressed + ) // The button keeps its normal tap behaviour; this only adds the hold. .simultaneousGesture( DragGesture(minimumDistance: 0) @@ -1794,11 +1917,43 @@ private struct ShiftButton: View { if scenePhase != .active { cancelRepeat() } } .accessibilityLabel("Shift \(title.lowercased())") + .accessibilityValue(externallyPressed ? "Pressed" : "") .accessibilityHint( disabled ? "You are already in the last gear" : hint ) } + @ViewBuilder + private var styledButton: some View { + if isProminent { + plainButton.buttonStyle(.borderedProminent) + } else { + // Tinted rather than neutral: a grey slab beside a solid one reads + // as unavailable, and this button is anything but. + plainButton + .buttonStyle(.bordered) + .background( + Color.accentColor.opacity(disabled ? 0 : 0.16), + in: RoundedRectangle(cornerRadius: 24) + ) + } + } + + private var plainButton: some View { + Button(action: tapped) { + VStack(spacing: 6) { + Image(systemName: symbol) + .font(.system(size: symbolSize, weight: .black, design: .rounded)) + .frame(height: symbolSize) + Text(title) + .font(.title2.weight(.bold)) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + private func tapped() { if let suppressTapUntil, Date() < suppressTapUntil { self.suppressTapUntil = nil @@ -1852,44 +2007,55 @@ private struct GearPositionRail: View { @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiate var body: some View { - GeometryReader { proxy in + let markers = GearRail.markers( + count: gears.count, + selected: selectedIndex, + requested: requestedIndex + ) + return GeometryReader { proxy in let spacing: CGFloat = 4 let width = max( 3, - (proxy.size.width - spacing * CGFloat(max(0, gears.count - 1))) - / CGFloat(max(1, gears.count)) + (proxy.size.width - spacing * CGFloat(max(0, markers.count - 1))) + / CGFloat(max(1, markers.count)) ) - HStack(spacing: spacing) { - ForEach(Array(gears.indices), id: \.self) { index in + // Bottom-aligned, so the filled gears behind the rider read as a + // level rising through the range rather than a row of dots that can + // only be understood by counting them. + HStack(alignment: .bottom, spacing: spacing) { + ForEach(Array(markers.enumerated()), id: \.offset) { _, marker in Capsule() - .fill(fill(for: index)) - .frame(width: width, height: index == selectedIndex ? 34 : 16) + .fill(fill(marker)) + .frame( + width: width, + height: Self.height * marker.relativeHeight + ) .overlay { - if isTarget(index) || (differentiate && index == selectedIndex) { + if marker == .requested + || (differentiate && marker == .selected) { Capsule().stroke(Color.accentColor, lineWidth: 2) } } } } - .frame(maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } - .frame(height: 40) + .frame(height: Self.height) // The big readout directly above already says which gear this is, and // saying it twice makes a rider swipe past the same fact to reach the // shift buttons. This is a picture of what that number means. .accessibilityHidden(true) } - private func fill(for index: Int) -> Color { - if index == selectedIndex { return .accentColor } - return .secondary.opacity(0.35) - } + private static let height: CGFloat = 44 - /// The gear the rider asked for is outlined until the trainer confirms it, - /// so a tap is acknowledged without ever showing it as the current gear. - private func isTarget(_ index: Int) -> Bool { - guard let requestedIndex, requestedIndex != selectedIndex else { return false } - return index == requestedIndex + private func fill(_ marker: GearRailMarker) -> Color { + switch marker { + case .selected: .accentColor + case .requested: .accentColor.opacity(0.45) + case .behind: .accentColor.opacity(0.4) + case .ahead: .secondary.opacity(0.3) + } } } diff --git a/VirtualGearsUITests/VirtualGearsUITests.swift b/VirtualGearsUITests/VirtualGearsUITests.swift new file mode 100644 index 0000000..acf30cb --- /dev/null +++ b/VirtualGearsUITests/VirtualGearsUITests.swift @@ -0,0 +1,543 @@ +import XCTest + +@MainActor +final class VirtualGearsUITests: XCTestCase { + private var app: XCUIApplication! + + func testStartingScreenShowsEveryConfiguredEquipmentStatus() { + launch("-shotStarting") + + assertVisible("screen.startup") + XCTAssertTrue(app.navigationBars["Virtual Gears"].exists) + XCTAssertTrue(app.buttons["Settings"].exists) + XCTAssertTrue(app.buttons["Try Demo"].exists) + assertStatusItems(["trainer", "click", "headwind"]) + } + + func testStartingScreenUsesWaitingStateInsteadOfDisabledStartAction() { + launch("-shotStarting") + + assertVisibleElement(app.buttons["Waiting for trainer"]) + XCTAssertFalse(app.buttons["Start Shifting"].exists) + } + + func testReadyScreenShowsTrainerAndRidingAppAdvertisingStatus() { + launch("-shotReady") + + XCTAssertTrue(app.staticTexts["Ready to shift"].waitForExistence(timeout: 3)) + assertStatusItems(["trainer", "click", "headwind", "riding-app"]) + let ridingApp = app.descendants(matching: .any)["status.riding-app"] + XCTAssertTrue(ridingApp.label.contains("Riding app")) + XCTAssertTrue(ridingApp.label.contains("Waiting for connection")) + } + + func testReadyScreenUsesPlatformNeutralNonDuplicativeStatusCopy() { + launch("-shotReady") + + let trainer = app.descendants(matching: .any)["status.trainer"] + let click = app.descendants(matching: .any)["status.click"] + let fan = app.descendants(matching: .any)["status.headwind"] + let ridingApp = app.descendants(matching: .any)["status.riding-app"] + XCTAssertEqual(trainer.label, "Wahoo KICKR 2A93, Connected") + XCTAssertEqual(click.label, "Zwift Click, Connected") + XCTAssertEqual(fan.label, "KICKR HEADWIND 4D21, Connected") + XCTAssertEqual(ridingApp.label, "Riding app, Waiting for connection") + } + + func testRideShowsAllStatusIconsAndPrimaryControls() { + launch("-shotRide") + + assertVisible("screen.ride") + assertVisibleElement(app.buttons["Shift easier"]) + assertVisibleElement(app.buttons["Shift harder"]) + assertVisibleElement(app.buttons["Settings"]) + assertVisibleElement(app.buttons["Headwind controls"]) + assertVisibleElement(app.buttons["Stop virtual shifting"]) + assertVisibleElement(app.descendants(matching: .any)["Gear"].firstMatch) + assertStatusItems(["kickr", "click", "headwind", "ridingapp"]) + } + + func testRideKeepsEveryStatusIndependentlyVisible() { + launch("-shotRide") + + for identifier in ["kickr", "click", "headwind", "ridingapp"] { + let item = app.descendants(matching: .any)["status.\(identifier)"] + assertVisibleElement(item) + } + } + + func testAccessibilityRideKeepsStatusWordsAndToolbarSeparated() { + launch("-shotRideAccessibility") + + for identifier in ["kickr", "click", "headwind", "ridingapp"] { + let item = app.descendants(matching: .any)["status.\(identifier)"] + assertVisibleElement(item) + XCTAssertLessThanOrEqual( + item.frame.height, + 70, + "\(identifier) wrapped into a tall, broken label" + ) + } + let gearMenu = app.buttons.matching( + NSPredicate(format: "label CONTAINS %@", "gears") + ).firstMatch + assertVisibleElement(gearMenu) + for control in ["Settings", "Headwind controls"] { + XCTAssertTrue( + app.buttons[control].frame.intersection(gearMenu.frame).isNull, + "The gear menu overlaps \(control)" + ) + } + } + + func testRideWaitingForRidingAppKeepsEveryEquipmentStatus() { + launch("-shotRideWaiting") + + assertStatusItems(["kickr", "click", "headwind", "ridingapp"]) + XCTAssertTrue( + app.descendants(matching: .any)["status.ridingapp"].label + .contains("Waiting for connection") + ) + } + + func testRideShowsLowClickBatteryWithoutReplacingStatuses() { + launch("-shotRideLowBattery") + + assertStatusItems(["kickr", "click", "headwind", "ridingapp"]) + assertVisibleElement(app.descendants(matching: .any)["Click battery low, 15 percent"]) + } + + func testRideShowsPendingShiftFeedback() { + launch("-shotRidePending") + + assertVisibleElement(app.staticTexts["Shifting…"]) + assertStatusItems(["kickr", "click", "headwind", "ridingapp"]) + } + + func testAcceptedClickPressMarksMatchingButtonPressed() { + launch("-shotRidePressed") + + XCTAssertEqual(app.buttons["Shift harder"].value as? String, "Pressed") + XCTAssertNotEqual(app.buttons["Shift easier"].value as? String, "Pressed") + } + + func testReconnectProblemIsProminentAndPlainLanguage() { + launch("-shotRideReconnecting") + + let problem = app.descendants(matching: .any)["status.kickr"] + assertVisibleElement(problem) + XCTAssertGreaterThanOrEqual(problem.frame.height, 32) + XCTAssertTrue(problem.label.contains("Reconnecting")) + } + + func testReconnectStatusIsSpelledOutRatherThanShrunkToAnIcon() { + launch("-shotRideReconnecting") + + let status = app.descendants(matching: .any)["ride.status"] + assertVisibleElement(status) + XCTAssertEqual(status.label, "Control lost · reconnecting") + XCTAssertGreaterThan( + status.frame.width, + 200, + "The ride status is squeezed down to a wordless icon" + ) + } + + func testEveryEquipmentStatusSitsOnASingleRow() { + launch("-shotRide") + + let centres = ["kickr", "click", "headwind", "ridingapp"].map { + app.descendants(matching: .any)["status.\($0)"].frame.midY + } + let spread = (centres.max() ?? 0) - (centres.min() ?? 0) + XCTAssertLessThan( + spread, + 8, + "The equipment statuses are split across rows, orphaning one of them" + ) + } + + func testLowClickBatteryIsWarnedAboutRatherThanFootnoted() { + launch("-shotRideLowBattery") + + let note = app.descendants(matching: .any)["note.clickBattery"] + assertVisibleElement(note) + XCTAssertGreaterThanOrEqual( + note.frame.height, + 24, + "A dying shifter battery is drawn as quietly as a caption" + ) + } + + func testStartupFailureExplainsConflictAndOffersRetry() { + launch("-shotFailed") + + assertVisibleElement(app.staticTexts["Shifting could not start"]) + assertVisibleElement( + app.staticTexts[ + "Your trainer would not hand over control. Something else may still be connected to it." + ] + ) + assertVisibleElement(app.buttons["Try Again"]) + } + + func testRideStatusIconsRemainVisibleInLandscape() { + launch("-shotRide") + XCUIDevice.shared.orientation = .landscapeLeft + + assertVisible("screen.ride") + assertStatusItems(["kickr", "click", "headwind", "ridingapp"]) + assertVisibleElement(app.buttons["Shift easier"]) + assertVisibleElement(app.buttons["Shift harder"]) + } + + func testRideOpensSettingsFanAndStopConfirmation() { + launch("-shotRide") + + app.buttons["Settings"].tap() + assertVisible("screen.settings") + app.buttons["Done"].tap() + + app.buttons["Headwind controls"].tap() + assertVisible("screen.headwind") + app.buttons["Done"].tap() + + app.buttons["Stop virtual shifting"].tap() + XCTAssertTrue(app.staticTexts["Stop virtual shifting?"].waitForExistence(timeout: 2)) + XCTAssertTrue(app.buttons["Stop Shifting"].exists) + } + + func testStoppingRequiresConfirmationBeforeRideControlsDisappear() { + launch("-shotRide") + + app.buttons["Stop virtual shifting"].tap() + + assertVisible("screen.ride") + assertVisibleElement(app.buttons["Shift easier"]) + assertVisibleElement(app.buttons["Shift harder"]) + assertVisibleElement(app.buttons["Stop Shifting"]) + } + + func testCancellingTheStopConfirmationReturnsToTheRide() { + launch("-shotRide") + + app.buttons["Stop virtual shifting"].tap() + + assertVisibleElement(app.buttons["Stop Shifting"]) + assertVisibleElement(app.buttons["Cancel"]) + + app.buttons["Cancel"].tap() + + XCTAssertFalse( + app.buttons["Stop Shifting"].waitForExistence(timeout: 1), + "Cancelling left the confirmation on screen" + ) + assertVisible("screen.ride") + assertVisibleElement(app.buttons["Shift easier"]) + assertVisibleElement(app.buttons["Shift harder"]) + assertVisibleElement(app.buttons["Stop virtual shifting"]) + } + + func testSettingsNavigatesToEveryDestination() { + launch("-shotSettings") + + assertVisible("screen.settings") + for destination in ["Trainer", "Zwift Click", "Wahoo Headwind", "Gears"] { + let row = app.staticTexts[destination].firstMatch + assertVisibleElement(row) + row.tap() + XCTAssertTrue( + app.navigationBars[destination].waitForExistence(timeout: 2), + "\(destination) screen did not open" + ) + app.navigationBars.buttons.firstMatch.tap() + } + } + + func testVirtualGearChoiceShowsModeAndPreview() { + launch("-shotGears") + + assertVisible("screen.gears") + XCTAssertTrue(app.buttons["Virtual gears"].exists) + XCTAssertTrue(app.buttons["Copy a real bike"].exists) + assertVisibleElement(app.staticTexts["24 gears"].firstMatch) + XCTAssertFalse(app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] %@", "same hard end") + ).firstMatch.exists) + XCTAssertFalse(app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] %@", "very wide") + ).firstMatch.exists) + } + + func testGearModeCanSwitchBetweenVirtualAndRealBikeOptions() { + launch("-shotGears") + + app.buttons["Copy a real bike"].tap() + assertVisibleElement(app.staticTexts["Chainrings"]) + assertVisibleElement(app.staticTexts["Cassette"]) + + app.buttons["Virtual gears"].tap() + XCTAssertFalse(app.staticTexts["Chainrings"].exists) + XCTAssertFalse(app.staticTexts["Cassette"].exists) + assertVisibleElement(app.staticTexts["24 gears"].firstMatch) + } + + func testRealGearChoiceNavigatesToChainringsAndCassette() { + launch("-shotRealGears") + + assertVisible("screen.gears") + assertVisibleElement(app.staticTexts["Chainrings"]) + assertVisibleElement(app.staticTexts["Cassette"]) + + app.staticTexts["Chainrings"].tap() + XCTAssertTrue(app.navigationBars["Chainrings"].waitForExistence(timeout: 2)) + app.navigationBars.buttons.firstMatch.tap() + + app.staticTexts["Cassette"].tap() + XCTAssertTrue(app.navigationBars["Cassette"].waitForExistence(timeout: 2)) + } + + func testHeadwindControlsExposeModeSpeedAndPresets() { + launch("-shotHeadwind") + + assertVisible("screen.headwind") + XCTAssertTrue(app.buttons["Automatic"].exists) + XCTAssertTrue(app.buttons["Manual"].exists) + ["Fan off", "25 percent", "50 percent", "75 percent", "100 percent"].forEach { + assertVisibleElement(app.buttons[$0]) + } + assertVisibleElement(app.buttons["Slower"]) + assertVisibleElement(app.buttons["Faster"]) + } + + func testDemoSupportsShiftingSettingsAndFanControls() { + launch("-shotDemo") + + assertVisible("screen.demo") + XCTAssertTrue(app.navigationBars["Demo Ride"].exists) + assertVisibleElement( + app.staticTexts[ + "Try the same large shift buttons and gear display used during a real ride." + ] + ) + let gear = app.descendants(matching: .any)["Simulated gear"] + assertVisibleElement(gear) + let initialValue = gear.value as? String + app.buttons["Shift harder"].tap() + expectation( + for: NSPredicate(format: "value != %@", initialValue ?? ""), + evaluatedWith: gear + ) + waitForExpectations(timeout: 2) + + app.buttons["Settings"].tap() + assertVisible("screen.demo-settings") + app.buttons["Done"].tap() + + app.buttons["Fan"].tap() + assertVisible("screen.demo-headwind") + app.buttons["Manual"].tap() + assertVisibleElement(app.buttons["50 percent"]) + } + + func testDemoShiftButtonsAreDrawnLikeTheRideScreensAreWithDistinctWeight() { + launch("-shotDemo") + + let easier = app.buttons["Shift easier"] + let harder = app.buttons["Shift harder"] + assertVisibleElement(easier) + assertVisibleElement(harder) + + let easierColor = averageColor(of: easier) + let harderColor = averageColor(of: harder) + XCTAssertGreaterThan( + colorDistance(easierColor, harderColor), + 0.1, + "Demo's Easier and Harder buttons look identical, unlike the ride screen" + ) + } + + func testDemoCanShiftHarderAndEasierBackToItsStartingGear() { + launch("-shotDemo") + + let gear = app.descendants(matching: .any)["Simulated gear"] + assertVisibleElement(gear) + let initialValue = gear.value as? String + + app.buttons["Shift harder"].tap() + expectation( + for: NSPredicate(format: "value != %@", initialValue ?? ""), + evaluatedWith: gear + ) + waitForExpectations(timeout: 2) + + app.buttons["Shift easier"].tap() + expectation( + for: NSPredicate(format: "value == %@", initialValue ?? ""), + evaluatedWith: gear + ) + waitForExpectations(timeout: 2) + } + + func testDemoFanPresetsChangeTheDisplayedSpeed() { + launch("-shotDemo") + + app.buttons["Fan"].tap() + assertVisible("screen.demo-headwind") + app.buttons["Manual"].tap() + app.buttons["75 percent"].tap() + + XCTAssertTrue(app.buttons["75 percent"].isSelected) + XCTAssertFalse(app.buttons["50 percent"].isSelected) + } + + private func launch(_ fixture: String) { + continueAfterFailure = false + app = XCUIApplication() + addTeardownBlock { @MainActor [weak self] in + guard let self, let app = self.app else { return } + if app.state == .runningForeground { + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = self.name + screenshot.lifetime = .keepAlways + self.add(screenshot) + } + app.terminate() + self.app = nil + } + XCUIDevice.shared.orientation = .portrait + app.launchArguments = [ + fixture, "-AppleLanguages", "(en)", "-AppleLocale", "en_US", + ] + app.launch() + } + + func testTheChainReminderNeverAppearsOrDisappearsAcrossStartupStates() { + // It used to live only inside the searching and chooser cards, so it + // vanished the instant the trainer connected and the button above it + // jumped up to fill the gap. It must now be part of the fixed layout, + // present in every startup state. + let reminderText = "Use the smaller front ring if your bike has one. " + + "Pick a rear gear that keeps the chain straight, and leave it " + + "there." + + launch("-shotStarting") + assertVisibleElement(app.staticTexts[reminderText]) + + launch("-shotReady") + assertVisibleElement(app.staticTexts[reminderText]) + + launch("-shotFailed") + assertVisibleElement(app.staticTexts[reminderText]) + } + + /// Samples the average colour of an element as it is actually rendered. + /// Button styling (bordered vs borderedProminent, tint) is not exposed on + /// the accessibility tree, so the only honest way to test "these two + /// buttons must not look identical" is to look at the pixels. + private func averageColor(of element: XCUIElement) -> (r: Double, g: Double, b: Double) { + let screenshot = element.screenshot().image + guard let cgImage = screenshot.cgImage else { return (0, 0, 0) } + let width = cgImage.width + let height = cgImage.height + guard width > 0, height > 0, + let data = cgImage.dataProvider?.data, + let pointer = CFDataGetBytePtr(data) else { return (0, 0, 0) } + let bytesPerPixel = cgImage.bitsPerPixel / 8 + let bytesPerRow = cgImage.bytesPerRow + var totals = (r: 0.0, g: 0.0, b: 0.0) + var samples = 0.0 + // Sample a sparse grid rather than every pixel: fast, and averages out + // the icon/text drawn on top of the button's own fill colour. + let strideStep = max(1, min(width, height) / 12) + for y in stride(from: 0, to: height, by: strideStep) { + for x in stride(from: 0, to: width, by: strideStep) { + let offset = y * bytesPerRow + x * bytesPerPixel + guard offset + 2 < CFDataGetLength(data) else { continue } + totals.r += Double(pointer[offset]) + totals.g += Double(pointer[offset + 1]) + totals.b += Double(pointer[offset + 2]) + samples += 1 + } + } + guard samples > 0 else { return (0, 0, 0) } + return (totals.r / samples / 255, totals.g / samples / 255, totals.b / samples / 255) + } + + private func colorDistance( + _ a: (r: Double, g: Double, b: Double), + _ b: (r: Double, g: Double, b: Double) + ) -> Double { + (pow(a.r - b.r, 2) + pow(a.g - b.g, 2) + pow(a.b - b.b, 2)).squareRoot() + } + + private func assertStatusItems( + _ identifiers: [String], + file: StaticString = #filePath, + line: UInt = #line + ) { + var frames: [(String, CGRect)] = [] + for identifier in identifiers { + assertVisible("status.\(identifier)", file: file, line: line) + assertVisible( + "status.\(identifier).icon", + file: file, + line: line + ) + frames.append(( + identifier, + app.descendants(matching: .any)["status.\(identifier)"].frame + )) + } + for index in frames.indices { + for otherIndex in frames.indices where otherIndex > index { + let overlap = frames[index].1.intersection(frames[otherIndex].1) + XCTAssertTrue( + overlap.isNull || overlap.width * overlap.height < 1, + "\(frames[index].0) overlaps \(frames[otherIndex].0)", + file: file, + line: line + ) + } + } + } + + private func assertVisible( + _ identifier: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + assertVisibleElement( + app.descendants(matching: .any)[identifier], + file: file, + line: line + ) + } + + private func assertVisibleElement( + _ element: XCUIElement, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertTrue( + element.waitForExistence(timeout: 3), + "\(element) does not exist", + file: file, + line: line + ) + let frame = element.frame + XCTAssertGreaterThan(frame.width, 1, file: file, line: line) + XCTAssertGreaterThan(frame.height, 1, file: file, line: line) + let windowFrame = app.windows.firstMatch.frame + let visibleFrame = frame.intersection(windowFrame) + let visibleArea = visibleFrame.isNull ? 0 : visibleFrame.width * visibleFrame.height + let totalArea = frame.width * frame.height + XCTAssertGreaterThanOrEqual( + visibleArea / totalArea, + 0.95, + "\(element) is clipped or outside the visible window", + file: file, + line: line + ) + } +} diff --git a/docs/APP_STORE.md b/docs/APP_STORE.md index 9a495c4..f055413 100644 --- a/docs/APP_STORE.md +++ b/docs/APP_STORE.md @@ -76,12 +76,13 @@ the iPhone and when advertising itself as a trainer. Virtual Gears supplies and displays its own gears on the iPhone through an ordinary FTMS trainer connection; it does not support Zwift's native gear system. - You get a full set of gears you can shift through mid-ride — either 24 very wide - virtual gears or an exact copy of the gears on your real bike. + You get a full set of gears you can shift through mid-ride — either 24 evenly + spaced gears with an extra-low climbing range or an exact copy of the gears on + your real bike. NOTHING TO SET UP - Open the app and it starts. It finds your trainer, connects to it, puts your - gears on and appears to your riding app on its own. Optional original Zwift Click and + Open the app and it finds your trainer, connects to it and appears to your + riding app. Tap Start Shifting when you want it to put your gears on. Optional original Zwift Click and Wahoo Headwind accessories are found and remembered automatically too. If more than one matching device is found, Virtual Gears asks rather than guessing. If Clicks have identical names, pressing a button identifies the one you want. @@ -117,7 +118,8 @@ the iPhone and when advertising itself as a trainer. OPTIONAL SHIFT BUTTONS Wake an original Zwift Click before opening Virtual Gears and it connects - automatically. It shifts alongside the on-screen buttons, but is never required. + automatically. It shifts alongside the on-screen buttons and visibly presses + the matching control, but is never required. OPTIONAL HEADWIND CONTROL Turn on a Wahoo KICKR HEADWIND before opening Virtual Gears and it connects @@ -264,12 +266,49 @@ For each update, raise `MARKETING_VERSION` (1.0 → 1.1) and upload again. `CURRENT_PROJECT_VERSION` must increase on every single upload, even a re-upload of the same version. -The current review build is 1.0 (6). Build 5 added the Demo Mode that shows the -wheel size and command bytes changing. Build 6 removes a wheel-size limit that -was never real: a physical KICKR V5 accepts every value the command can express, -so the app now states the range of riding-app wheel sizes it supports instead of -guessing at a trainer limit. It also stops describing itself to riders as -starting a session, because it does not — it puts their gears on. +The current TestFlight build is 1.0 (8). Build 5 added the Demo Mode that shows +the wheel size and command bytes changing. Build 6 removed a wheel-size limit +that was never real: a physical KICKR V5 accepts every value the command can +express, so the app now states the range of riding-app wheel sizes it supports +instead of guessing at a trainer limit. It also stopped describing itself to +riders as starting a session, because it does not — it puts their gears on. +Build 7 mirrors an accepted Zwift Click press on the matching on-screen shift +button while keeping the gear number and haptic tied to trainer confirmation. +Build 8 restores the explicit Start Shifting step while making the transparent +trainer proxy available as soon as the KICKR is ready, and keeps every configured +equipment status visible on the shifting screen. + +Build 1.0 (10) was uploaded to TestFlight on 15 August 2026. Build 9 was never +uploaded, so build 10 carries everything since build 8. It fixes the case where a +riding +app waiting to connect replaced the KICKR, Click and fan statuses; each connection +now keeps its own status. It also adds adaptive status layout for +Accessibility Dynamic Type, clearer startup/status/gear/demo wording, and +deterministic UI coverage for failure, reconnect, waiting, low-battery, +pending-shift and physical-press states. It moves the ride status out of the +title bar, where it was squeezed until only a wordless warning icon remained, to +a legible line beside the gear, and gives the stop confirmation a visible Cancel +so a rider is never shown a destructive choice with no way out. The ride screen +was then reworked around what only it can show: the position rail fills in the +gears already ridden through so a position can be seen instead of counted, the +line under the gear became a caption rather than a rival to it, easier and harder +are told apart by weight as well as by symbol, every equipment status sits on one +row, and a low Click battery is drawn as a warning. Settings stopped saying +"Zwift Click" twice in one row, the gears row leads with the gears chosen rather +than a count of them, cassettes that share a name are told apart by their cog +count, and retrying a failed start says "Try Again". + +Every change in build 10 was made against the simulator. The gear rail, shift +button weighting, one-row status footer and battery warning have not yet been +checked on a physical KICKR while riding. + +Build 10 was ridden on a physical KICKR and surfaced two bugs, both fixed for +build 11: the chain-position reminder was omitted from the "ready to start +shifting" state, so it vanished the instant the trainer connected and made the +Start Shifting button jump upward; it is now shown in a fixed position across +every startup state. Demo Mode's shift buttons never received the +easier/harder weight differentiation added in build 10, so they still looked +like two equally prominent buttons; they now match the ride screen's styling. The live App Store description still carries the old "starts the session" sentence. It is corrected in this file and needs the same edit in App Store diff --git a/docs/accessibility.md b/docs/accessibility.md index 7df2a70..e14f403 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -17,11 +17,18 @@ button continues shifting. Equipment, connection problems, battery warnings and Bluetooth errors have spoken descriptions rather than relying only on icons or colour. +Pressing the optional Zwift Click visibly presses the matching on-screen +button. This visual acknowledgement is separate from the gear announcement +and haptic, which occur only after the trainer confirms the shift. + ## Display and motion The ride controls use large targets in portrait and landscape. At Accessibility Dynamic Type sizes, the ride screen uses its vertical layout even when the phone is sideways so controls have room to reflow instead of being compressed. +Equipment statuses reflow into a two-column grid at those sizes, keeping short +names on one line instead of breaking words apart. The toolbar shortens the gear +menu label so it cannot overlap the equipment controls. Virtual Gears respects the iPhone's Reduce Motion setting for the gear-number transition. Connection and status states use names and symbols alongside diff --git a/docs/demo.md b/docs/demo.md index 69b04d0..818b151 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -41,9 +41,10 @@ advertise, connect or send Bluetooth commands.
![The saved trainer connecting while the Zwift Click and Headwind are connected](screenshots/starting.png){ width="280" } -
Open the app and it goes looking for your trainer. There is - nothing to press for a real ride. Try Demo is available when no trainer is - nearby. Optional equipment reconnects by itself.
+
Open the app and it goes looking for your trainer. Once connected, + Start Shifting applies the gears; the riding app remains responsible for the + ride itself. Try Demo is available when no trainer is nearby. Optional + equipment reconnects by itself.
diff --git a/docs/index.md b/docs/index.md index 076f0db..feda9c6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -75,7 +75,9 @@ list of tooth counts can tell you. Two large buttons on the phone, placed for sweaty hands and a locked-out gaze. An original Zwift Click can be added and shifts the same gears, but it is never -required and nothing ever waits for it. +required and nothing ever waits for it. Its physical press is mirrored on the +matching phone button; the gear number still changes only after the trainer +confirms the shift. The current gear is also an adjustable VoiceOver control: swipe up for a harder gear and down for an easier one. Confirmed gear changes are announced, so the @@ -97,9 +99,9 @@ switching to another fan. ### Nothing to set up -Open the app. It looks for your trainer, connects to it, puts your gears on -and appears to your riding app, all on its own. There is no setup to complete and no -button to press. +Open the app. It looks for your trainer, connects to it and appears to your +riding app. When the trainer is ready, tap **Start Shifting** to put your gears +on. The riding app owns the ride; this button only controls Virtual Gears. The only question it asks is which device, and only when it finds more than one trainer, Click or Headwind. A single device is simply used. Bluetooth signal @@ -112,6 +114,10 @@ Headwind and riding-app status. Demo Mode never uses Bluetooth and never changes saved equipment. It is a tour of the product, not evidence that untested physical hardware works. +During shifting, the trainer, Click, fan and riding app each keep their own +status. A riding app that is still waiting to connect does not hide +the equipment Virtual Gears already connected. + ### Everything changeable mid-ride Trainer, gears, Click and Headwind can all be changed from the ride screen. diff --git a/docs/requirements.md b/docs/requirements.md index 8429267..794ce9e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -91,9 +91,12 @@ idea of the bike and leaves the road alone. own. If it finds more than one trainer, it asks you which one. 3. On your computer, open your riding app and pair with the trainer it offers. That will be your iPhone, appearing as a trainer. -4. Ride. Shift with the two large buttons on the phone. +4. Tap **Start Shifting**, then ride and shift with the two large buttons on the + phone. -There is no setup screen to complete first and no start button to press. +There is no setup screen to complete first. The app connects to the trainer and +makes it available to the riding app automatically; **Start Shifting** only +applies the virtual gears. You can explore the app without any equipment by tapping **Try Demo** while it looks for a trainer. The simulated ride does not use Bluetooth or control @@ -118,8 +121,10 @@ About → Name — but that changes its name everywhere else too. Wake the Click by pressing a button before opening Virtual Gears. The app finds, connects and remembers it automatically. It shifts the same gears as the -on-screen buttons. Nothing ever waits for it, so a Click that runs out of -battery mid-ride costs you nothing but the buttons on the handlebar. +on-screen buttons, and pressing it visibly presses the matching phone button. +The gear number still changes only after the trainer confirms the shift. +Nothing ever waits for the Click, so one that runs out of battery mid-ride +costs you nothing but the buttons on the handlebar. If the app finds more than one Click, choose yours in Settings rather than letting it guess. Some Clicks advertise the same name. In that case, keep diff --git a/docs/screenshots/riding.png b/docs/screenshots/riding.png index 8f88c78..f8258d4 100644 Binary files a/docs/screenshots/riding.png and b/docs/screenshots/riding.png differ