diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index d54dd49..d0337ad 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -34,10 +34,10 @@ body: required: true - type: input - id: iphone + id: apple-device attributes: - label: iPhone and iOS version - placeholder: iPhone 14 Pro, iOS 18.2 + label: Apple device and OS version + placeholder: iPhone 17 Pro on iOS 26.6, or iPad mini on iPadOS 26.6 validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf5bace..cc99287 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,15 +66,27 @@ jobs: CODE_SIGNING_ALLOWED=NO ui-tests: - name: UI tests + name: UI tests (${{ matrix.destination }}) runs-on: macos-26 + strategy: + fail-fast: false + matrix: + destination: + - iPhone 17 Pro + - iPad mini (A17 Pro) + - iPad Pro 13-inch (M5) steps: - uses: actions/checkout@v7 + - name: Prepare simulator + run: | + xcrun simctl boot "${{ matrix.destination }}" 2>/dev/null || true + xcrun simctl bootstatus "${{ matrix.destination }}" -b + - name: Run UI regression tests run: | xcodebuild test \ -project VirtualGears.xcodeproj \ -scheme VirtualGears \ - -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -destination "platform=iOS Simulator,name=${{ matrix.destination }}" \ -parallel-testing-enabled NO diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5f0ce76..da45eac 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -10,11 +10,16 @@ physical hardware measurements behind it. Rider instructions live in the - macOS - Xcode 26 or later - Swift 6 -- An iPhone running iOS 17 or later for Bluetooth and trainer testing +- An iPhone or iPad running iOS/iPadOS 17 or later for Bluetooth and trainer testing The simulator can build and display the app, but it cannot prove Bluetooth behavior with a trainer, controller or fan. +The app target and UI-test target are universal (`TARGETED_DEVICE_FAMILY = +"1,2"`). iPad supports portrait, upside-down portrait and both landscape +orientations without requiring full screen, so normal full screen, Split View +and Stage Manager remain available. + The in-app Demo Mode is intentionally simulator-safe. `DemoRideState` contains only a drivetrain and selected gear, while its `ConfigurationStore` has no `UserDefaults` backing. Entering the demo stops discovery and the app suppresses @@ -56,6 +61,18 @@ xcodebuild test \ -parallel-testing-enabled NO ``` +Run the same suite on the representative iPads used by CI: + +```bash +for destination in 'iPad mini (A17 Pro)' 'iPad Pro 13-inch (M5)'; do + xcodebuild test \ + -project VirtualGears.xcodeproj \ + -scheme VirtualGears \ + -destination "platform=iOS Simulator,name=$destination" \ + -parallel-testing-enabled NO +done +``` + Run the dense setup and Settings journeys on the smaller supported simulator: ```bash @@ -79,7 +96,9 @@ The matrix includes the setup guide, startup, ride, Settings, every equipment destination, virtual and physical gearing, Headwind and Demo Mode. It also includes Accessibility Dynamic Type for the wizard, Settings and ride; landscape ride and Headwind layouts; dark-mode ride and Headwind controls; and -the dense wizard and Settings journeys on the smaller iPhone 17e. Assertions +the dense wizard and Settings journeys on the smaller iPhone 17e. The full suite +also runs on an iPad mini and 13-inch iPad, with iPad-specific ready/ride, +sheet, landscape and upside-down assertions. Assertions check the state-specific message and action, plus important layout and visual invariants. Whole-screen pixel comparisons are deliberately avoided; pixel sampling is used only when XCTest cannot expose a meaningful property such as @@ -103,22 +122,23 @@ matrix. Every response Virtual Gears owns after those events is still represente Protocol behavior and equipment lifecycle remain covered by the package tests and physical-hardware evidence. -Open the iPhone project: +Open the app project: ```bash open VirtualGears.xcodeproj ``` -Select the `VirtualGears` scheme. To run on an iPhone: +Select the `VirtualGears` scheme. To run on a physical iPhone or iPad: 1. Sign in to Xcode with the Apple ID used for device development. 2. Select the `VirtualGears` target. 3. Open **Signing & Capabilities** and choose the development team. -4. Select the physical iPhone as the run destination. +4. Select the physical device as the run destination. 5. Run the app. -CI performs the same package tests and builds the app for an iPhone simulator -without code signing. +CI performs the same package tests, builds the universal app for the iOS +simulator without code signing, and runs UI tests on iPhone 17 Pro, iPad mini +and iPad Pro 13-inch. ## Repository layout @@ -130,13 +150,66 @@ without code signing. | `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 | +| `VirtualGears.xcodeproj` | Universal iPhone and iPad app project | `DemoRideStateTests` cover the simulated gear ladder and drivetrain changes. Bluetooth safety still depends on keeping Demo Mode outside `ProxyCoordinator` and the CoreBluetooth services; do not replace its local state with staged production services. +## Native iPad release gate + +Simulator coverage verifies app-owned layout and navigation at compact iPhone +widths and regular full-screen iPad widths. The same geometry-driven layout is +used when an iPad window becomes compact, but Split View and Stage Manager still +need the physical iPad check below. CoreBluetooth behavior must also be checked +on physical hardware. + +On 25 August 2026 the iPad was connected by USB and paired successfully: + +```text +xcrun devicectl list devices +iPad Air (2) — iPad14,10 — iPadOS 26.6.1 (23G83) +UDID 00008112-001C381E3441A01E — wired — available (paired) +``` + +Developer Mode was enabled, and automatic provisioning registered the iPad. +The signed development build was then built, installed and launched successfully +with bundle identifier `com.sbroenne.VirtualGears`. `devicectl` confirmed the app +was running in the foreground on the unlocked iPad at its native 2732×2048 +display resolution. + +```text +Virtual Gears 1.0 (18) — com.sbroenne.VirtualGears — installed +/private/var/containers/Bundle/Application/.../VirtualGears.app/VirtualGears +``` + +Physical XCTest automation was attempted twice, including a single test, but +the runner timed out after 60 seconds while enabling automation mode. Launch is +therefore physically verified while app-owned layouts and multitasking remain +covered by the simulator matrix rather than physical XCTest. + +The first physical Click discovery attempt found the original Click but remained +on "Found one. Checking for others…" after the search window. A direct Mac probe +then found the same Click at -59 dBm, connected, reported a 100% battery and read +its button presses, ruling out a sleeping or unavailable accessory. The settings +discovery window now keeps an absolute view-state deadline driven by a timer +rather than a SwiftUI `.task(id:)`, so candidate-driven view updates cannot +discard the deadline. The single-candidate regression passes on iPhone and iPad +simulators. The corrected build was installed on the iPad on 25 August 2026; +after the Click was woken, the startup screen found and selected the sole +original Click automatically. Physical sole-device discovery is therefore +validated. + +The complete required Bluetooth gate was then checked manually on the same iPad +Air with the physically validated KICKR V5 and original Zwift Click: KICKR +discovery and connection, FTMS advertising to a riding app, Start Shifting, +confirmed easier and harder shifts including Click input, Stop Shifting, normal +wheel-circumference restoration and an uninterrupted riding-app connection all +worked. The Headwind path was not repeated on iPad; its existing physical iPhone +evidence remains the supported claim. With this evidence recorded, build 19 is +the first universal build eligible for TestFlight upload. + The proxy and shifting have deliberately separate lifecycles. Once the saved KICKR is ready, `ProxyCoordinator.makeProxyAvailable()` publishes the FTMS trainer and transparently forwards data and supported commands. `startShifting` diff --git a/README.md b/README.md index 238f0ab..9f822b9 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ **Virtual shifting for Wahoo KICKR trainers — even in riding apps that do not offer it.** -Virtual Gears puts an iPhone between your trainer and the app you ride with. +Virtual Gears puts an iPhone or iPad between your trainer and the app you ride +with. Your riding app still controls the route and its hills; Virtual Gears adds the gears. Because it appears as a normal FTMS trainer, the riding app does not need to know anything about virtual shifting. @@ -36,10 +37,10 @@ available to ordinary FTMS riding apps without changing their route data. | | Requirement | |---|---| -| **iPhone** | iOS 17 or later | +| **Apple device** | iPhone or iPad running iOS/iPadOS 17 or later | | **Trainer** | A compatible direct-drive Wahoo KICKR | | **Riding app** | An app that can connect to an FTMS trainer | -| **Shifting** | Large iPhone buttons; optional original Zwift Click | +| **Shifting** | Large on-screen buttons; optional original Zwift Click | | **Fan** | Optional Wahoo KICKR HEADWIND | Virtual Gears was built and physically tested with a KICKR V5. Other @@ -47,6 +48,11 @@ direct-drive KICKR models are expected to work but have not yet been physically tested. The KICKR SNAP, KICKR BIKE and trainers from other brands are not supported. +The Bluetooth path has been physically tested on iPhone and iPad with a KICKR +V5 and original Zwift Click. The interface and ready/ride flows are also covered +on iPad mini and 13-inch iPad simulators, including portrait, landscape and +accessibility text. + On the riding-app side, FulGaz on macOS and Windows, plus RealVelo and MyWhoosh on Windows, have been ridden end to end. Other FTMS riding apps are expected to work but have not been tested. @@ -56,15 +62,15 @@ work but have not been tested. ## Your first ride 1. Wake the KICKR by turning the pedals. -2. Open Virtual Gears on the iPhone. It finds the KICKR, connects and makes it - available to your riding app. +2. Open Virtual Gears on the iPhone or iPad. It finds the KICKR, connects and + makes it available to your riding app. 3. Tell the required first-run setup what is physically on the bike: chainrings, and either a cassette or a Zwift Cog/other single sprocket. Your first ride uses Standard 24 virtual gears automatically. 4. Move the chain to the quiet, reachable gear Virtual Gears recommends and confirm it. 5. In your riding app, connect to the trainer named **Virtual Gears**. Some apps - may show the iPhone's name instead. + may show the device's name instead. 6. Tap **Start Shifting**, then shift with the large **Easier** and **Harder** buttons. @@ -75,7 +81,7 @@ gears; **Stop Shifting** removes them without disconnecting or stopping the ride in your riding app. If it finds more than one trainer, it asks you to choose yours by name. -The iPhone screen stays awake while the trainer proxy is available. This keeps +The device screen stays awake while the trainer proxy is available. This keeps Virtual Gears discoverable to riding apps on Windows and other computers before they connect. @@ -99,7 +105,8 @@ with physical hardware. - **Gears that shift the way an electronic groupset shifts**, one cog at a time with the front change folded in, so there are no dead shifts and no invented gaps. -- **On-phone shifting** with large controls in portrait and landscape. +- **On-device shifting** with large controls in portrait and landscape, including + iPad full screen, Split View and Stage Manager. - **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 @@ -114,6 +121,16 @@ with physical hardware. ## Screenshots +

+ + Virtual Gears ride controls on a 13-inch iPad, showing gear 12 and large Easier and Harder buttons + + + Virtual Gears first-run bike setup on a 13-inch iPad + +

+

Native iPad ride and setup layouts

+
@@ -178,7 +195,7 @@ Every gear change waits for confirmation from the trainer. - **ERG workouts are not supported.** ERG mode controls target power, while Virtual Gears controls how hard a gear feels. - **The riding app cannot display the selected gear.** Bluetooth FTMS has no - message for reporting it, so the gear is shown on the iPhone. + message for reporting it, so the gear is shown on your iPhone or iPad. - **This is not Zwift's native virtual shifting.** It works independently of the riding app. Virtual Gears supplies and displays the gears itself. - **Virtual Gears cannot read the trainer's current wheel circumference.** It @@ -188,8 +205,8 @@ Every gear change waits for confirmation from the trainer. custom value in the Wahoo app, enter the same value in Virtual Gears before shifting. - **A riding app on Windows may not reconnect on its own after a Bluetooth - drop.** If the wireless link between your phone and the computer times out - mid-ride, some Windows riding apps do not scan for the phone again by + drop.** If the wireless link between your Apple device and the computer times + out mid-ride, some Windows riding apps do not scan for the device again by themselves; you may need to restart the riding app to see Virtual Gears once more. This is the riding app's own reconnect behaviour, not something Virtual Gears controls. diff --git a/VirtualGears.xcodeproj/project.pbxproj b/VirtualGears.xcodeproj/project.pbxproj index 44dd9ac..abd4221 100644 --- a/VirtualGears.xcodeproj/project.pbxproj +++ b/VirtualGears.xcodeproj/project.pbxproj @@ -260,7 +260,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = VirtualGears; }; name = Debug; @@ -276,7 +276,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = VirtualGears; }; name = Release; @@ -320,7 +320,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = MNW6SJT4V7; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = VirtualGearsProduct/Info.plist; @@ -336,7 +336,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -345,7 +345,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = MNW6SJT4V7; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = VirtualGearsProduct/Info.plist; @@ -361,7 +361,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; diff --git a/VirtualGearsProduct/ClickCentralService.swift b/VirtualGearsProduct/ClickCentralService.swift index 6a47e41..59fc6ff 100644 --- a/VirtualGearsProduct/ClickCentralService.swift +++ b/VirtualGearsProduct/ClickCentralService.swift @@ -567,6 +567,10 @@ extension ClickCentralService { func stageScreenshotPressedButton(_ button: ZwiftClickButton) { pressedButton = button } + + func stageDiscoveredCandidate(_ candidate: BluetoothCandidate) { + candidates = [candidate] + } } #endif diff --git a/VirtualGearsProduct/Info.plist b/VirtualGearsProduct/Info.plist index 223fa86..434fb9d 100644 --- a/VirtualGearsProduct/Info.plist +++ b/VirtualGearsProduct/Info.plist @@ -46,5 +46,12 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + diff --git a/VirtualGearsProduct/SetupView.swift b/VirtualGearsProduct/SetupView.swift index 386ae04..32e1e35 100644 --- a/VirtualGearsProduct/SetupView.swift +++ b/VirtualGearsProduct/SetupView.swift @@ -1,3 +1,4 @@ +import Combine import SwiftUI import VirtualGearsCore @@ -302,6 +303,7 @@ private struct NormalWheelSizeView: View { @Bindable var store: ConfigurationStore @State private var enteredValue: String @State private var isApplyingDefault = false + @FocusState private var wheelSizeFieldIsFocused: Bool init(store: ConfigurationStore) { self.store = store @@ -368,6 +370,7 @@ private struct NormalWheelSizeView: View { Section { TextField("Millimetres", text: $enteredValue) .keyboardType(.numberPad) + .focused($wheelSizeFieldIsFocused) .onChange(of: enteredValue) { _, value in if isApplyingDefault { isApplyingDefault = false @@ -425,6 +428,15 @@ private struct NormalWheelSizeView: View { } .navigationTitle("Wheel circumference") .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + wheelSizeFieldIsFocused = false + } + .accessibilityIdentifier("wheel.dismissKeyboard") + } + } } private var defaultMillimeters: Int { @@ -635,6 +647,16 @@ private struct ShiftingSetupView: View { if store.configuration.usesClick { click.autoConnectSavedDevice() } +#if DEBUG + if ScreenshotFixture.current == .settingsClickSingleCandidate { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + click.stageDiscoveredCandidate(BluetoothCandidate( + id: ScreenshotFixture.clickID, + name: "Zwift Click" + )) + } +#endif } .onChange(of: click.latestButtonEvent) { _, event in guard click.identificationCandidateID != nil, let event else { return } @@ -1670,8 +1692,14 @@ private struct DeviceDiscoverySection: View { let select: (BluetoothCandidate) -> Void @State private var discovery = DeviceDiscoveryState() + @State private var searchDeadline: Date? private let searchDuration = DeviceDiscoveryPolicy.searchDuration + private let searchTicks = Timer.publish( + every: 0.25, + on: .main, + in: .common + ).autoconnect() init( deviceName: String, @@ -1805,17 +1833,16 @@ private struct DeviceDiscoverySection: View { beginSearch() } } - .task(id: DiscoveryClock( + .onChange(of: DiscoveryClock( scanGeneration: scanGeneration, isScanning: isScanning - )) { - guard isScanning else { return } - do { - try await Task.sleep(for: searchDuration) - } catch { - return - } - guard !Task.isCancelled, isScanning else { return } + ), initial: true) { _, clock in + updateSearchClock(clock) + } + .onReceive(searchTicks) { now in + guard let searchDeadline, now >= searchDeadline, + isScanning else { return } + self.searchDeadline = nil finishSearch() } .onChange(of: candidates.count) { _, count in @@ -1830,6 +1857,7 @@ private struct DeviceDiscoverySection: View { } } .onDisappear { + searchDeadline = nil if discovery.phase != .idle || isScanning { cancelScanning() } @@ -1864,6 +1892,14 @@ private struct DeviceDiscoverySection: View { } } + private func updateSearchClock(_ clock: DiscoveryClock) { + searchDeadline = clock.isScanning + ? Date.now.addingTimeInterval( + TimeInterval(searchDuration.components.seconds) + ) + : nil + } + private func finishSearch() { stopScanning() if candidates.count == 1, let candidate = candidates.first, diff --git a/VirtualGearsProduct/VirtualGearsApp.swift b/VirtualGearsProduct/VirtualGearsApp.swift index 4c4c6b4..ae6186e 100644 --- a/VirtualGearsProduct/VirtualGearsApp.swift +++ b/VirtualGearsProduct/VirtualGearsApp.swift @@ -329,13 +329,9 @@ private struct ScreenshotFixtureView: View { name: "Zwift Click" ), ] - let clickCandidates = scenario == .settingsClickSingleCandidate - ? [BluetoothCandidate( - id: ScreenshotFixture.clickID, - name: "Zwift Click" - )] - : (scenario == .settingsClickDuplicates - || scenario == .settingsClickIdentifying ? duplicateClicks : []) + let clickCandidates = + scenario == .settingsClickDuplicates + || scenario == .settingsClickIdentifying ? duplicateClicks : [] click.stageScreenshot( name: configuration.clickName, batteryLevel: scenario == .settingsClickLowBattery ? 15 : 82, diff --git a/VirtualGearsProduct/VirtualGearsHomeView.swift b/VirtualGearsProduct/VirtualGearsHomeView.swift index bac5e8e..0d1f297 100644 --- a/VirtualGearsProduct/VirtualGearsHomeView.swift +++ b/VirtualGearsProduct/VirtualGearsHomeView.swift @@ -1171,6 +1171,8 @@ struct ShiftingView: View { } .padding(.horizontal, landscape ? 18 : 14) .padding(.bottom, 4) + .frame(maxWidth: 1_100, maxHeight: .infinity) + .frame(maxWidth: .infinity) } .navigationTitle(configuration.drivetrainName) .navigationBarTitleDisplayMode(.inline) @@ -1368,7 +1370,8 @@ struct ShiftingView: View { gearHero( fontSize: min( geometry.size.width * 0.61, - geometry.size.height * 0.38 + geometry.size.height * 0.38, + 320 ) ) HStack(spacing: 12) { @@ -1378,7 +1381,8 @@ struct ShiftingView: View { .frame( height: min( max(160, geometry.size.height * 0.32), - geometry.size.height * 0.36 + geometry.size.height * 0.36, + 300 ) ) .accessibilityElement(children: .contain) @@ -1387,14 +1391,15 @@ struct ShiftingView: View { } private func landscapeControls(_ geometry: GeometryProxy) -> some View { - let buttonWidth = max(150, geometry.size.width * 0.28) + let buttonWidth = min(320, max(150, geometry.size.width * 0.28)) return HStack(spacing: 12) { shiftButton(easier: true) .frame(width: buttonWidth) gearHero( fontSize: min( geometry.size.width * 0.18, - geometry.size.height * 0.46 + geometry.size.height * 0.46, + 300 ) ) shiftButton(easier: false) diff --git a/VirtualGearsUITests/VirtualGearsUITests.swift b/VirtualGearsUITests/VirtualGearsUITests.swift index db2902a..5554fb1 100644 --- a/VirtualGearsUITests/VirtualGearsUITests.swift +++ b/VirtualGearsUITests/VirtualGearsUITests.swift @@ -184,6 +184,11 @@ final class VirtualGearsUITests: XCTestCase { assertVisible("screen.settings") app.staticTexts["Zwift Click"].firstMatch.tap() + XCTAssertTrue( + app.staticTexts["Found one. Checking for others…"] + .waitForExistence(timeout: 4), + "The fixture did not discover its sole Click after scanning began" + ) XCTAssertTrue( app.staticTexts["Your Click"].waitForExistence(timeout: 12), "A sole Click was not selected when the discovery window ended" @@ -353,6 +358,60 @@ final class VirtualGearsUITests: XCTestCase { XCTAssertTrue(app.buttons["Stop Shifting"].exists) } + func testIPadReadyAndRideFlowsRemainUsableAcrossOrientations() throws { + guard UIDevice.current.userInterfaceIdiom == .pad else { + throw XCTSkip("iPad layout coverage") + } + + launch("-shotReady") + assertVisible("screen.startup") + assertVisibleElement(app.buttons["Start Shifting"]) + app.buttons["Settings"].tap() + assertVisible("screen.settings") + assertVisibleElement(app.buttons["Done"]) + app.buttons["Done"].tap() + + launch("-shotRide", orientation: .landscapeLeft) + waitForLandscapeLayout() + let window = app.windows.firstMatch.frame + let easier = app.buttons["Shift easier"] + let harder = app.buttons["Shift harder"] + assertVisibleElement(easier) + assertVisibleElement(harder) + XCTAssertLessThanOrEqual( + easier.frame.width, + 320, + "The easier control grows beyond its deliberate iPad width." + ) + XCTAssertLessThanOrEqual( + harder.frame.width, + 320, + "The harder control grows beyond its deliberate iPad width." + ) + XCTAssertLessThan(easier.frame.maxX, harder.frame.minX) + XCTAssertTrue(window.contains(easier.frame)) + XCTAssertTrue(window.contains(harder.frame)) + + XCUIDevice.shared.orientation = .portraitUpsideDown + let deadline = Date().addingTimeInterval(3) + while Date() < deadline, + app.windows.firstMatch.frame.width > app.windows.firstMatch.frame.height { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + XCTAssertEqual( + XCUIDevice.shared.orientation, + .portraitUpsideDown, + "The simulated iPad did not enter upside-down portrait." + ) + XCTAssertGreaterThan( + app.windows.firstMatch.frame.height, + app.windows.firstMatch.frame.width, + "The iPad did not rotate to upside-down portrait." + ) + assertVisibleElement(app.buttons["Shift easier"]) + assertVisibleElement(app.buttons["Shift harder"]) + } + func testStoppingRequiresConfirmationBeforeRideControlsDisappear() { launch("-shotRide") @@ -489,6 +548,9 @@ final class VirtualGearsUITests: XCTestCase { XCTAssertFalse( app.buttons["MTB, 29×2.25, 2326 millimetres"].isSelected ) + let dismissKeyboard = app.buttons["wheel.dismissKeyboard"] + assertVisibleElement(dismissKeyboard) + dismissKeyboard.tap() let useDefault = app.buttons["wheel.useDefault"] XCTAssertTrue(useDefault.isEnabled) @@ -857,8 +919,7 @@ final class VirtualGearsUITests: XCTestCase { app.buttons["Fan"].tap() assertVisible("screen.demo-headwind") - app.buttons["Manual"].tap() - assertVisibleElement(app.buttons["50 percent"]) + selectDemoManualFanControl() } func testDemoShiftButtonsAreDrawnLikeTheRideScreensAreWithDistinctWeight() { @@ -1317,12 +1378,28 @@ final class VirtualGearsUITests: XCTestCase { app.buttons["Fan"].tap() assertVisible("screen.demo-headwind") capture(.demoHeadwindAutomatic) - app.buttons["Manual"].tap() - assertVisibleElement(app.buttons["50 percent"]) + selectDemoManualFanControl() capture(.demoHeadwindManual) assertJourneyCoverage() } + private func selectDemoManualFanControl() { + let manual = app.buttons["Manual"] + XCTAssertTrue(manual.waitForExistence(timeout: 3)) + let deadline = Date().addingTimeInterval(3) + while Date() < deadline, !manual.isHittable { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + XCTAssertTrue(manual.isHittable, "Manual fan control is not tappable") + manual.tap() + expectation( + for: NSPredicate(format: "isSelected == true"), + evaluatedWith: manual + ) + waitForExpectations(timeout: 3) + assertVisibleElement(app.buttons["50 percent"]) + } + private func openSettingsDestination(_ title: String, fixture: String) { launch(fixture) assertVisible("screen.settings") diff --git a/docs/APP_STORE.md b/docs/APP_STORE.md index 1af2afa..6e2e878 100644 --- a/docs/APP_STORE.md +++ b/docs/APP_STORE.md @@ -3,6 +3,14 @@ Everything Apple will ask you for, written out so you can copy and paste it. Work top to bottom. +!!! warning "Universal build uploaded; not submitted for review" + + The universal iPhone/iPad target, iPad screenshots and shared-device wording + are complete. Build 19 was uploaded on 25 August 2026, is available to the internal + `Insiders` TestFlight group, and is attached to App Store version 1.0. The + physical iPad Bluetooth gate in Stage 5 passed. The version has not been + submitted for App Review. + --- ## Stage 1 — Join the Apple Developer Program @@ -43,7 +51,7 @@ itself whenever `main` changes, so the policy cannot drift out of date. | User access | Full Access | Apple accepted `Virtual Gears` as the public name. The app uses the same name on -the iPhone and when advertising itself as a trainer. +iPhone and iPad and when advertising itself as a trainer. ## Stage 4 — Fill in the listing @@ -53,125 +61,81 @@ the iPhone and when advertising itself as a trainer. ### Promotional text (170 characters max, editable any time without review) - Give your Wahoo KICKR virtual gears in compatible FTMS riding apps. Shift on your iPhone with 24 virtual gears or real groupset gearing. + Give your Wahoo KICKR virtual gears in compatible FTMS riding apps. Shift on iPhone or iPad with 24 virtual gears or real groupset gearing. ### Description Your trainer has no gears. Your riding app may not offer virtual shifting. Virtual Gears fixes both. - Newer trainers gained virtual shifting in 2024. The Wahoo KICKR V5 did not, and - Wahoo has confirmed it never will — the older hardware cannot support it. Many - riding apps do not provide virtual shifting either. + Virtual Gears sits between your trainer and riding app. It appears as a normal + Bluetooth FTMS trainer, so compatible riding apps need no special virtual-shifting + support. Your riding app still controls the route and hills; Virtual Gears adds + the gears. - Virtual Gears was built and physically tested with a KICKR V5. Other direct-drive - KICKR models are expected to work but have not yet been physically tested. KICKR - SNAP and KICKR BIKE are not supported. + Built and physically tested with Wahoo KICKR V5. Other direct-drive KICKR models + are expected to work but have not been physically tested. KICKR SNAP, KICKR BIKE + and trainers from other brands are not supported. - Virtual Gears sits between both. It appears to compatible riding apps as a normal - FTMS trainer, so they need no special virtual-shifting support. Your riding app still - controls the route and its hills; Virtual Gears adds the gears. - - NOT ZWIFT-NATIVE VIRTUAL SHIFTING - 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. + START WITH THE BIKE + First-run setup asks what is physically on your bike: its chainrings and cassette, + Zwift Cog or other single sprocket. Virtual Gears recommends where to leave the + chain and starts with ready-made Standard 24 virtual gears. Named Shimano, SRAM + and Campagnolo groupsets and custom gearing remain available in Settings. - 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 the gearing of a real - groupset from Shimano, SRAM or Campagnolo. + SHIFT WITHOUT DISCONNECTING + Open the app and it finds your KICKR, connects, and makes the trainer available to + your riding app automatically. Tap Start Shifting to engage the gears. Tap Stop + Shifting to restore normal trainer gearing without disconnecting or stopping the + ride in your riding app. - START WITH THE BIKE - Required first-run setup asks only what is physically on the bike: the - chainrings and either its cassette or a Zwift Cog/other single sprocket. Virtual - Gears then recommends where to leave the chain and starts with the ready-made - Standard 24 virtual gears. Named groupsets and custom gears remain available - later in Settings. - - ONE PHYSICAL FACT CANNOT BE GUESSED - Your bike never shifts. It stays in one gear and Virtual Gears changes gear by - changing the wheel size the trainer works from, so what your legs feel is that - parked gear multiplied by the wheel size the app sets. Virtual Gears therefore - needs to know which gear your bike is parked in. It works out the quietest gear - that still keeps every gear reachable and recommends it. If setup is unfinished, - Settings gives one next action: fix gearing first, then confirm the parked gear. - - A TRAINER PROXY, WITH SHIFTING WHEN YOU WANT IT - Open the app and it finds your trainer, connects to it and appears to your riding - app on its own. Tap Start Shifting to engage the gears and Stop Shifting to remove - them without disconnecting or stopping the ride in your riding app. 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. + This is not Zwift-native virtual shifting. Virtual Gears supplies and displays its + own gears on your iPhone or iPad through an ordinary FTMS connection. GEARS YOU CAN SEE - Your gears are drawn, not listed as numbers — one bar per gear, short bars for - small steps and tall bars for the ones your legs will notice. Choose the 24 - virtual gears, or pick the groupset your bike has — Shimano, SRAM or Campagnolo, - with your own chainrings and cassette available if it is not listed — and get the - gears you would actually ride. The ladder is walked the way an electronic - groupset shifts: one cog at a time, changing chainring at the right moment, so - cross-chained and repeated combinations never appear and no shift is too small - to feel. - - Virtual Gears is not affiliated with or endorsed by Zwift, Wahoo, Shimano, SRAM - or Campagnolo. Those names describe only the gearing being simulated. - - BUILT FOR RIDING, NOT FOR READING - Two large shift buttons stay easy to hit without looking down or sitting up, - while the current gear is the biggest thing on screen. Hold one to keep shifting. - The screen stays awake while the trainer proxy is available, including before - shifting starts and after shifting stops. - - ACCESSIBLE RIDE CONTROLS + The gear ladder is drawn rather than shown as a bare number: short bars for small + steps and taller bars for changes your legs will notice. Use 24 wide-range virtual + gears or copy a real groupset. Cross-chained and duplicate combinations are left + out. + + BUILT FOR RIDING + Two large shift buttons are easy to hit without looking down, while the current + gear is the largest thing on screen. Hold a button to keep shifting. A gear appears + only after the trainer confirms it, and every gear stays inside a range tested on + a real KICKR V5. + VoiceOver reads the current gear and announces confirmed changes. The gear readout - is adjustable with VoiceOver gestures: swipe up for a harder gear and down for an - easier one. The app uses standard iPhone controls and respects larger text, Reduce + supports adjustable VoiceOver gestures. The app respects larger text, Reduce Motion and Differentiate Without Color. TRY IT WITHOUT A TRAINER - Tap Try Demo for a clearly marked simulated ride without Bluetooth. Shift through - the gear ladder and watch the trainer's wheel size change with every gear. Compare - virtual and real-bike gearing, and try example Click, Headwind and riding-app - controls. Demo choices never replace saved equipment. - - WORKS WITH THE APPS YOU ALREADY USE - Virtual Gears appears as an ordinary FTMS indoor trainer. That adds virtual shifting - to compatible apps that have none of their own, without requiring a plugin or - account. Apps that set their own wheel size are honoured — the gears are rebuilt - around whatever size the app asks for. If an app sends no size, Virtual Gears uses - the optional Wheel circumference saved in Settings, or the 2105 mm (700×25 road) - default. - - OPTIONAL SHIFT BUTTONS - Wake an original Zwift Click before opening Virtual Gears and it connects - 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 - automatically. Leave fan speed with the Headwind's own sensor, or choose a manual - speed from the ride screen. The fan is optional and never blocks a ride. - - CAREFUL WITH YOUR TRAINER - A gear is only shown after your trainer confirms it. Every gear stays inside a - range tested on a real trainer. + Tap Try Demo for a clearly marked simulated ride without Bluetooth. Explore the + production gear picker, large ride controls, virtual and real-bike gearing, and + example equipment status. Demo choices never replace saved equipment. - NOT AN ERG APP - Workouts that set a target power are refused. Virtual Gears controls how hard a - gear feels, not your wattage. + OPTIONAL ZWIFT CLICK + Wake an original Zwift Click and Virtual Gears finds and remembers it automatically. + It shifts alongside the on-screen buttons and visibly presses the matching control, + but is never required. - NOTE ON WHEEL CIRCUMFERENCE - Virtual Gears cannot read a wheel circumference previously set in the Wahoo app. - If you use a custom value, enter the same value as Wheel circumference in - Virtual Gears Settings before shifting. Common-size shortcuts and direct entry - support values from 1800 to 2400 mm. A value sent by the riding app takes precedence. + OPTIONAL KICKR HEADWIND + Connect a Wahoo KICKR HEADWIND and leave control with its own sensor or choose a + manual speed from the ride screen. The fan is optional and never blocks a ride. - NO ACCOUNTS, NO INTERNET, NO TRACKING - The app has no networking code in it at all. Nothing about your ride leaves your - iPhone. + COMPATIBLE RIDING APPS + Virtual Gears works as an ordinary FTMS indoor trainer. Apps that set a wheel size + are honoured: gears are rebuilt around that size. Otherwise the app uses your + optional saved circumference or a 2105 mm default. - Requires a compatible Wahoo KICKR. Built and physically tested with KICKR V5. - Virtual Gears is not made by, endorsed by or affiliated with Wahoo Fitness. + NOT AN ERG APP + Workouts that set target power are refused. Virtual Gears controls how hard a gear + feels, not your wattage. + + NO ACCOUNTS, INTERNET OR TRACKING + The app has no networking code. Nothing about your ride leaves your device. + + Virtual Gears is not affiliated with or endorsed by Wahoo, Zwift, Shimano, SRAM or + Campagnolo. Those names describe compatible hardware or simulated gearing only. ### Keywords (100 characters max, comma separated, no spaces) @@ -200,7 +164,7 @@ Issues and Discussions must stay enabled on the repository. ### Screenshots -Required: **6.9-inch iPhone**. The six portrait images listed below are +Required iPhone set: **6.9-inch iPhone**. The six portrait images listed below are 1320 × 2868, captured on an iPhone 17 Pro Max simulator, so they can be uploaded as they are. Apple scales them down for smaller phones; one set is enough. The two landscape images are 2868 × 1320 documentation views; they are @@ -219,6 +183,20 @@ Upload these six, in this order: 6. `headwind-control.png` — optional Automatic/Manual Headwind control with one-tap speeds. +Required iPad set: **13-inch iPad**. The matching portrait images under +`docs/app-store/ipad-13/` are 2064 × 2752 simulator captures: + +1. `riding.png` +2. `setup.png` +3. `bike-setup.png` +4. `parked-gear.png` +5. `gears.png` +6. `headwind-control.png` + +These assets prove the app-owned layout at App Store dimensions. They do not +prove Bluetooth behavior. All six were uploaded to the 13-inch iPad screenshot +set with build 19 after the physical iPad gate below passed. + ### Privacy answers ("App Privacy" section) Answer **"No, we do not collect data from this app."** That is accurate: nothing is @@ -227,6 +205,34 @@ the app. ## Stage 5 — Upload the build +Before changing `CURRENT_PROJECT_VERSION`, archiving or uploading: + +1. Confirm `xcrun devicectl list devices` shows a physical iPad. +2. Install the development build on that iPad. +3. Connect to the physical KICKR, complete setup and advertise Virtual Gears to a + riding app. +4. Start shifting, confirm easier and harder changes, stop shifting, and confirm + the normal wheel circumference is restored without dropping the riding app. +5. If available, also connect the original Zwift Click and Headwind. Record + exactly what was tested in `DEVELOPMENT.md`. + +An iPad Air (`iPad14,10`, iPadOS 26.6.1) was connected by USB and paired on +25 August 2026. After Developer Mode was enabled, automatic provisioning +registered the device and the signed 1.0 (18) development build installed and +launched successfully. Physical XCTest still times out while enabling automation +mode. The corrected build physically found and selected the sole original Zwift +Click automatically on the startup screen. The same iPad then passed the complete +KICKR flow above, including connected Click shifts, normal wheel-size restoration +and riding-app continuity across Stop. The physical Bluetooth gate is complete; +simulator coverage remains the evidence for the automated layout matrix. + +Build 19 was archived and uploaded successfully on 25 August 2026. App Store +Connect reports it as `Validated`, with minimum iOS 17.0, arm64 architecture and +device family `iPhone, iPad`. It is `Ready to Submit` in TestFlight, belongs to +the internal `Insiders` group, and is attached to App Store version 1.0. The +universal promotional text, description, review notes and six iPad screenshots +were saved live. The version remains unsubmitted for review. + In Xcode: 1. Choose **Any iOS Device** as the destination (not a simulator). @@ -234,22 +240,23 @@ In Xcode: 3. When the Organizer opens: **Distribute App** → **App Store Connect** → **Upload**. 4. Accept the automatic signing offers. -The build appears in App Store Connect after 5–15 minutes of processing. Attach it -to the version, then **Add for Review** → **Submit**. +The build appears in App Store Connect after processing. Attach it to the +version, then use **Add for Review** → **Submit** only when the release should be +sent to App Review. ## Stage 6 — The review notes (do not skip this) The reviewer does not need a smart trainer to inspect the app, but App Review still asked for a screen recording. Both matter. -This is what is live in **App Review Information → Notes**. It answers, in -order, the seven questions App Review asked when they rejected 1.0 (5) under -Guideline 2.1: +This note block was saved live with build 19 after the physical iPad gate. It +answers, in order, the seven questions App Review asked when they rejected +1.0 (5) under Guideline 2.1: VIRTUAL GEARS - APP REVIEW NOTES WHAT THE APP DOES, AND FOR WHOM (point 3) - A Wahoo KICKR indoor bicycle trainer has no gears. Virtual Gears adds them. The iPhone connects to the trainer as a Bluetooth client and at the same time presents itself to a riding app on a computer as a standard FTMS indoor bike. It passes the riding app's resistance instructions through to the trainer and relays the trainer's data back, and applies the rider's chosen gear by rescaling the trainer's wheel-circumference setting. That is what produces the gears. Audience: indoor cyclists whose trainer or riding app offers no gear shifting. It solves that without buying a new trainer. + A Wahoo KICKR indoor bicycle trainer has no gears. Virtual Gears adds them. The iPhone or iPad connects to the trainer as a Bluetooth client and at the same time presents itself to a riding app on a computer as a standard FTMS indoor bike. It passes the riding app's resistance instructions through to the trainer and relays the trainer's data back, and applies the rider's chosen gear by rescaling the trainer's wheel-circumference setting. That is what produces the gears. Audience: indoor cyclists whose trainer or riding app offers no gear shifting. It solves that without buying a new trainer. HOW TO SET UP AND REACH THE MAIN FEATURES (point 4) No account, login, credentials or sample files are needed. No hardware is needed either. @@ -257,11 +264,12 @@ Guideline 2.1: With hardware: switch the KICKR on and open the app. It finds and connects on its own. Then point any FTMS riding app on a computer at "Virtual Gears" and shift with the on-screen buttons or an original Zwift Click. DEVICES AND SYSTEMS TESTED ON (point 2) - iPhone 17 Pro, iOS 26.6. + Physically tested: iPhone 17 Pro, iOS 26.6; iPad Air (iPad14,10), iPadOS 26.6.1. + Simulator-tested interface: iPad mini (A17 Pro) and iPad Pro 13-inch (M5), portrait and landscape. Trainer: Wahoo KICKR V5. Accessories: original Zwift Click, Wahoo KICKR HEADWIND. Riding apps driven end to end: FulGaz on macOS and Windows, plus RealVelo and MyWhoosh on Windows. EXTERNAL SERVICES, TOOLS AND PLATFORMS (point 5) - None. The app contains no networking code at all. There are no servers, accounts, analytics, adverts, tracking, payment processors, data providers or AI services. Everything happens on the device and over local Bluetooth. Nothing leaves the phone. + None. The app contains no networking code at all. There are no servers, accounts, analytics, adverts, tracking, payment processors, data providers or AI services. Everything happens on the device and over local Bluetooth. Nothing leaves the device. REGIONAL DIFFERENCES (point 6) None. The app behaves identically in every region. It has no region-specific features, content or pricing behaviour. @@ -298,7 +306,7 @@ 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 TestFlight build is 1.0 (18). Build 5 added the Demo Mode that shows +The current TestFlight build is 1.0 (19). 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 @@ -389,9 +397,12 @@ saved Click. The corrected development build was physically checked on the iPhone 17 Pro with the original Click that exposed the regression. Build 17 should not be submitted for App Review; use build 18. -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 -Connect on the next metadata change. +Build 19 makes the target universal for iPhone and iPad while +keeping iOS/iPadOS 17 as the minimum. It adds all four iPad orientations without +requiring full screen, caps ride controls at deliberate iPad sizes, and runs the +UI suite on iPhone 17 Pro, iPad mini and iPad Pro 13-inch simulators. The +physical iPad gate above passed before build 19 was uploaded. Its universal +description and screenshots are now live in the editable App Store version. Uploading without opening Xcode: diff --git a/docs/DEMO_VIDEO.md b/docs/DEMO_VIDEO.md index 7417ffe..32cf396 100644 --- a/docs/DEMO_VIDEO.md +++ b/docs/DEMO_VIDEO.md @@ -35,12 +35,17 @@ Their wording, and what it means here: The remaining items in their list — account registration, purchases, user-generated content — do not exist in this app. There is nothing to film. +The existing recording remains valid evidence for the physically tested iPhone +path. The core connect/advertise/shift/stop flow was also repeated on a physical +iPad Air running iPadOS 26.6.1 before build 19; that evidence is recorded in +`DEVELOPMENT.md`. Simulator footage is not a substitute for that Bluetooth check. + ## Before you start **Delete the app from the phone first.** Deleting it takes its Bluetooth permission with it, so the prompt appears again on the next launch, and the app starts with no remembered trainer — exactly what an App Review device sees. -Then install the latest TestFlight build, currently 1.0 (18), but **do not open +Then install the latest TestFlight build, currently 1.0 (19), but **do not open it**. The recording has to start from the Home screen. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 72ee370..04e9f68 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy for Virtual Gears -Last updated: 8 August 2026 +Last updated: 25 August 2026 ## The short version @@ -9,14 +9,14 @@ the app has no way to send anything anywhere. ## What the app stores -Virtual Gears remembers a few things on your iPhone so you don't have to set them up +Virtual Gears remembers a few things on your device so you don't have to set them up again each ride: - Which trainer you last used, so it can reconnect on its own. - Which gears you chose. - Which optional shift controller or Headwind fan it should reconnect to. -These stay on your iPhone. Deleting the app deletes them. +These stay on your iPhone or iPad. Deleting the app deletes them. Demo Mode uses a separate in-memory setup. Its simulated equipment and gear choices are discarded when you leave the demo and are not written over the diff --git a/docs/accessibility.md b/docs/accessibility.md index e14f403..61af836 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -25,16 +25,16 @@ and haptic, which occur only after the trainer confirms the shift. 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. +device 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 +Virtual Gears respects the device's Reduce Motion setting for the gear-number transition. Connection and status states use names and symbols alongside colour; the gear rail adds an outline when Differentiate Without Color is on. -## Other iPhone accessibility features +## Other iPhone and iPad accessibility features The app uses standard SwiftUI buttons, menus, pickers, sliders and navigation controls. These retain their normal support for Switch Control, Voice Control, @@ -47,7 +47,7 @@ Try these settings with **Try Demo** before a real ride: 1. Turn on VoiceOver in **Settings > Accessibility > VoiceOver** and adjust the simulated gear readout. 2. Choose an Accessibility text size in **Settings > Accessibility > Display & - Text Size > Larger Text**, then rotate the phone. + Text Size > Larger Text**, then rotate the device. 3. Turn on **Reduce Motion** and **Differentiate Without Color** in **Settings > Accessibility > Display & Text Size**. diff --git a/docs/app-store/ipad-13/bike-setup.png b/docs/app-store/ipad-13/bike-setup.png new file mode 100644 index 0000000..7f5634c Binary files /dev/null and b/docs/app-store/ipad-13/bike-setup.png differ diff --git a/docs/app-store/ipad-13/gears.png b/docs/app-store/ipad-13/gears.png new file mode 100644 index 0000000..5e40610 Binary files /dev/null and b/docs/app-store/ipad-13/gears.png differ diff --git a/docs/app-store/ipad-13/headwind-control.png b/docs/app-store/ipad-13/headwind-control.png new file mode 100644 index 0000000..dcc7d43 Binary files /dev/null and b/docs/app-store/ipad-13/headwind-control.png differ diff --git a/docs/app-store/ipad-13/parked-gear.png b/docs/app-store/ipad-13/parked-gear.png new file mode 100644 index 0000000..5e2b422 Binary files /dev/null and b/docs/app-store/ipad-13/parked-gear.png differ diff --git a/docs/app-store/ipad-13/riding.png b/docs/app-store/ipad-13/riding.png new file mode 100644 index 0000000..ebbfb7f Binary files /dev/null and b/docs/app-store/ipad-13/riding.png differ diff --git a/docs/app-store/ipad-13/setup.png b/docs/app-store/ipad-13/setup.png new file mode 100644 index 0000000..6443117 Binary files /dev/null and b/docs/app-store/ipad-13/setup.png differ diff --git a/docs/how-it-works.md b/docs/how-it-works.md index fd3e79d..7d172a9 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -1,6 +1,6 @@ # How the gears work -![Your riding app talks to the iPhone, the iPhone talks to the trainer, and a shift changes the wheel size the trainer is running](how-it-works.svg) +![Your riding app talks to Virtual Gears on an iPhone or iPad, Virtual Gears talks to the trainer, and a shift changes the wheel size the trainer is running](how-it-works.svg) Virtual Gears was built and physically tested with a KICKR V5. Other direct-drive KICKR models are expected to work but have not yet been physically @@ -9,7 +9,7 @@ size: a smaller wheel covers less ground per pedal stroke, which feels like an easier gear. The riding app's own terrain command is left untouched, so the two never fight. -Virtual Gears chooses and displays the gear on the iPhone. The riding app sees an +Virtual Gears chooses and displays the gear on your iPhone or iPad. The riding app sees an ordinary FTMS trainer and does not need a virtual-shifting feature of its own. Opening Virtual Gears connects this transparent trainer proxy. **Start Shifting** @@ -17,8 +17,8 @@ adds the gears; **Stop Shifting** removes the virtual gear and restores the normal wheel circumference. Neither button starts or stops the ride in the riding app, and stopping shifting leaves that app connected. -The iPhone screen stays awake for as long as this trainer proxy is available. -iOS changes Bluetooth advertising after the phone locks, which can make a +The device screen stays awake for as long as this trainer proxy is available. +iOS and iPadOS change Bluetooth advertising after the device locks, which can make a waiting trainer disappear from riding apps on Windows and other computers. ## The gear your bike is parked in diff --git a/docs/index.md b/docs/index.md index a91ed40..a9bf944 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,9 +8,9 @@ none.** Use the smaller front ring if your bike has one, then pick a rear gear that keeps -the chain straight and leave it there. Shifting happens on your iPhone instead, -which changes how hard the trainer feels. Nothing on the bike moves, so there is -no chain noise, no dropped chain, and no wear. +the chain straight and leave it there. Shifting happens on your iPhone or iPad +instead, which changes how hard the trainer feels. Nothing on the bike moves, so +there is no chain noise, no dropped chain, and no wear. !!! quote "Why this exists" @@ -39,10 +39,11 @@ no chain noise, no dropped chain, and no wear. ## What it is -Your riding app connects to the iPhone instead of to the trainer. Virtual Gears -passes everything through in both directions, and applies your chosen gear on -top. The riding app still controls the road: hills feel like hills. It does not -need special virtual-shifting support; ordinary FTMS trainer support is enough. +Your riding app connects to your iPhone or iPad instead of to the trainer. +Virtual Gears passes everything through in both directions, and applies your +chosen gear on top. The riding app still controls the road: hills feel like +hills. It does not need special virtual-shifting support; ordinary FTMS trainer +support is enough. ![How Virtual Gears sits between your riding app and your trainer](how-it-works.svg) @@ -57,6 +58,15 @@ speed. Both remain optional and never hold up a ride. First-run setup with Zwift Cog or another single sprocket selected

+

+ The native 13-inch iPad ride screen showing gear 12 and the large Easier and Harder controls + The native 13-inch iPad first-run bike setup screen +

+ +The iPad layout uses the same app-owned screens and actions rather than a +separate tablet workflow. Controls stay bounded in full screen and reflow through +the existing compact layout as Split View or Stage Manager narrows the window. + ## What you get ### Start with the bike @@ -133,16 +143,16 @@ list of tooth counts can tell you. ### Shifting -Two large buttons on the phone, placed for sweaty hands and a locked-out gaze. +Two large buttons on the screen, 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. Its physical press is mirrored on the -matching phone button; the gear number still changes only after the trainer +matching on-screen 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 ride can be controlled without looking at the screen. [Accessibility -details](accessibility.md) cover larger text and other iPhone settings. +details](accessibility.md) cover larger text and other iPhone and iPad settings. ### Headwind control diff --git a/docs/requirements.md b/docs/requirements.md index 225960b..854d4a9 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -2,10 +2,10 @@ | | | |---|---| -| **iPhone** | Running iOS 17 or later — any iPhone from the XS onwards | +| **Apple device** | An iPhone or iPad running iOS/iPadOS 17 or later | | **Trainer** | A compatible direct-drive Wahoo KICKR. [See below](#which-trainers-work). | | **Riding app** | An app that can connect to an FTMS trainer. | -| **Shifting** | Large buttons on the iPhone. An original Zwift Click is optional. | +| **Shifting** | Large on-screen buttons. An original Zwift Click is optional. | | **Fan** | Optional. A Wahoo KICKR HEADWIND. | ## Which trainers work @@ -64,12 +64,12 @@ That is the gap Virtual Gears fills. Three differences worth knowing: - **Your riding app will not display the gear.** Zwift can show it because Zwift decides it. Virtual Gears decides it, and there is no way to tell a riding app what gear you are in — the Bluetooth standard has no such message. The gear is - shown large on the phone instead. + shown large on your iPhone or iPad instead. - **Virtual Gears does not require virtual-shifting support in the riding app.** It appears as an ordinary FTMS trainer, bringing gears to compatible apps that do not provide their own. - **Virtual Gears does not support Zwift-native virtual shifting.** It supplies - and displays its own gears on the iPhone. + and displays its own gears on your iPhone or iPad. [wahoo]: https://support.wahoofitness.com/hc/en-us/articles/16865097915666-Virtual-shifting-with-Wahoo-smart-trainers @@ -105,16 +105,21 @@ MyWhoosh ignored FTMS ride data in direct testing and read power and cadence from Cycling Power Service instead. Virtual Gears now publishes that service, and the complete iPhone-to-Windows path has been ridden end to end. +The iPad interface is exercised on iPad mini and 13-inch iPad simulators. That +proves the layouts and navigation, not Bluetooth. The first universal release +remains blocked until a physical iPad has connected to the KICKR, advertised the +trainer proxy, shifted, stopped and restored the normal wheel size. + ## Your first ride 1. Put your bike on the trainer and wake the trainer by turning the pedals. -2. Open Virtual Gears on your iPhone. It finds the trainer and connects on its - own, then appears as a trainer. If it finds more than one trainer, it asks - you which one. +2. Open Virtual Gears on your iPhone or iPad. It finds the trainer and connects + on its own, then appears as a trainer. 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. + That will be your Apple device, appearing as a trainer. 4. Tap **Start Shifting** when you want gears. Shift with the two large buttons - on the phone. + on the device. The two-step first-run setup is required. It asks what is physically on the bike and then confirms the recommended parked chain position. Standard 24 virtual @@ -130,23 +135,23 @@ exercise equipment. A real ride still requires the compatible hardware above. ### What name to look for Virtual Gears tells your riding app it is called **Virtual Gears**, and most apps -show that. Some show your iPhone's own name instead — "iPhone Anna", say — +show that. Some show your Apple device's own name instead — "iPhone Anna", say — and a few add a short code after it. Either name is the right one to pick. There is only ever one of them in the list, because your trainer itself is already busy talking to Virtual Gears and so does not appear. -This is not something the app can fix. iPhones report their own name over +This is not something the app can fix. Apple devices report their own name over Bluetooth, and Apple does not let an app change it. If seeing "Virtual Gears" -in the list matters to you, rename the phone itself in Settings → General → +in the list matters to you, rename the device itself in Settings → General → About → Name — but that changes its name everywhere else too. ## Adding a Zwift Click 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, and pressing it visibly presses the matching phone button. +on-screen buttons, and pressing it visibly presses the matching on-screen 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. diff --git a/docs/support.md b/docs/support.md index 954198b..d13aefc 100644 --- a/docs/support.md +++ b/docs/support.md @@ -20,7 +20,7 @@ Most problems have a known answer already: - **The trainer is not found.** Turn the pedals to wake it, and close any other app that may already be connected to it. A trainer can only talk to one thing at a time. -- **The riding app shows your iPhone's name instead of "Virtual Gears".** That +- **The riding app shows your Apple device's name instead of "Virtual Gears".** That is normal and it is the right device to pick. See [What name to look for](requirements.md#what-name-to-look-for). - **Your trainer is not a KICKR V5.** Check @@ -40,7 +40,7 @@ Most problems have a known answer already: [If you set a custom wheel circumference](requirements.md#if-you-set-a-custom-wheel-circumference). - **A Windows riding app disconnected mid-ride and won't come back.** A weak Bluetooth link can time out, and some riding apps on Windows do not scan for - your phone again on their own afterwards. Restart the riding app to make it + your Apple device again on their own afterwards. Restart the riding app to make it look again. This has been confirmed to be the riding app's own reconnect behaviour, not a Virtual Gears fault — the trainer stayed connected and working the whole time. @@ -49,7 +49,7 @@ Most problems have a known answer already: The more of this you can give, the better the odds of a fix: -- Your trainer model, and your iPhone and iOS version +- Your trainer model, Apple device model, and iOS or iPadOS version - The riding app you were using, and on what computer - What you did, what you expected, and what happened instead - Whether the app showed an error, and its exact wording diff --git a/mkdocs.yml b/mkdocs.yml index fe1647e..d98a392 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,6 @@ site_name: Virtual Gears site_description: >- - Virtual gears for a smart trainer. An iPhone app that sits between your + Virtual gears for a smart trainer. An iPhone and iPad app that sits between your riding app and a Wahoo KICKR V5 and gives you gears the trainer does not have. site_url: https://sbroenne.github.io/VirtualGears/ repo_url: https://github.com/sbroenne/VirtualGears