Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4d49517
Build the gear ladder the way Di2 shifts, and ask which gear the bike…
web-flow Aug 17, 2026
7cd6fa6
Ask for the parked gear in setup, and offer groupsets and ladders by …
web-flow Aug 17, 2026
7038c29
Document the parked gear, the synchro walk and the groupsets
web-flow Aug 17, 2026
f1e782c
Confirm the parked gear in the UI and refresh the screenshots
Aug 18, 2026
ef92d8a
Ship build 12 to TestFlight
Aug 18, 2026
42c57fc
Fix setup rows rendering all-blue instead of black text + blue checkmark
Aug 18, 2026
cfee9a9
Bump build number to 13 for TestFlight
Aug 18, 2026
364bfff
Make the Start button open Settings when the parked gear is missing
Aug 18, 2026
5991f95
Add setup guide re-entry, custom gear ladder, and a Bluetooth disconn…
Aug 18, 2026
ef6132b
Bump build number to 14 for TestFlight
Aug 18, 2026
f54f058
Ship build 14 to TestFlight
Aug 18, 2026
88a247e
Let the setup wizard pair a real groupset with a single sprocket
Aug 18, 2026
1bffc2a
Fix wizard's single-sprocket toggle state and rename escape hatch
Aug 18, 2026
e7764ab
Address remaining UX review findings in the setup guide and Settings
Aug 18, 2026
5b945f2
Add complete designed-state UX coverage
Aug 19, 2026
ce18304
Simplify setup and refresh UX story
Aug 19, 2026
d9f780a
Bump build number to 15 for TestFlight
Aug 20, 2026
e368190
Ship build 15 to TestFlight
Aug 20, 2026
839f23a
Simplify first-run bike setup
Aug 20, 2026
19cca42
Bump build number to 16 for TestFlight
Aug 21, 2026
3913479
Ship build 16 to TestFlight
Aug 21, 2026
a2a0631
Polish setup and startup UX
Aug 21, 2026
a71bb5d
Bump build number to 17 for TestFlight
Aug 21, 2026
2f4bd7b
Ship build 17 to TestFlight
Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 101 additions & 18 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,24 +43,51 @@ xcodebuild test \
-parallel-testing-enabled NO
```

Run the dense setup and Settings journeys on the smaller supported simulator:

```bash
xcodebuild test \
-project VirtualGears.xcodeproj \
-scheme VirtualGears \
-destination 'platform=iOS Simulator,name=iPhone 17e' \
-parallel-testing-enabled NO \
-only-testing:VirtualGearsUITests/VirtualGearsUITests/testUXCoverageSetupWizardStates \
-only-testing:VirtualGearsUITests/VirtualGearsUITests/testUXCoverageSettingsAndEquipmentStates
```

`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
the simulator has Bluetooth hardware. `DesignedUXState` is the maintained
coverage contract: it lists every intentionally designed, app-owned screen,
modal, loading state, warning, error and recovery state. The journey tests cover
all 65 entries and retain a stable `UX-...` screenshot for each one. The
completeness test fails if a state is not assigned to an executable journey.

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
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
button emphasis.

To add or change a user-visible state:

1. Add a debug-only `ScreenshotFixture` launch route that stages the real
production view and model. Do not build a visual copy for the test.
2. Add the state to `DesignedUXState` and assign it to a journey in
`uxCoverageManifest`.
3. In that journey, launch or navigate to the state, assert its defining message
and available recovery action, then call `capture(_:)`.
4. Add an accessibility identifier only when the existing label is unstable or
SwiftUI combines several children into one element.
5. Run the manifest test and all `testUXCoverage...` journeys. A state is not
covered until its named screenshot is attached to a passing result.

OS-owned permission sheets and real Bluetooth timing are outside this simulator
matrix. Every response Virtual Gears owns after those events is still represented.
Protocol behavior and equipment lifecycle remain covered by the package tests and
physical-hardware evidence.

Open the iPhone project:
Expand Down Expand Up @@ -108,7 +135,7 @@ connected across Stop.

`AppConfiguration.normalWheelCircumferenceMillimeters` is optional on disk so
configurations saved by older builds still decode. Its effective value defaults
to 2070 mm and accepts 1800–2400 mm. A standard wheel-size command from the
to 2105 mm (700×25 road) and accepts 1800–2400 mm. A standard wheel-size command from the
riding app always takes precedence.

## Documentation website
Expand Down Expand Up @@ -692,3 +719,59 @@ Some things that look duplicated are not, and should not be merged:
- The fan reconnects through `resumeSavedConnection` rather than
`retrieveAndConnect`. The two are equivalent today, but making them the
same call would be a behaviour change wearing a refactor's clothes.

## Why the gear ladder is walked rather than sorted

The first version of `Drivetrain.build` paired every chainring with every cog,
sorted the pile by ratio, pruned the cross-chained pairs and dropped exact
duplicates. That is not how a drivetrain works, and the difference was
measurable across the groupsets the app ships.

Running both algorithms over all **72 builds** of the shipped groupsets — real
chainring and cassette pairings only, no invented combinations:

| Over 72 real groupset builds | Sorted pile | Synchro walk |
|---|---|---|
| Builds with a shift too small to feel | 12 | **0** |
| Builds with a hole above 25% | 5 | **0** |
| Smallest step anywhere | 0.4% | **5.9%** |
| Largest step anywhere | 37% | 25% |
| Easiest and hardest gear kept | always | always |

Both defects disappear rather than being patched. A walked drivetrain cannot
invent a hole, because it only ever moves one cog at a time, and cannot produce
a step under the perception floor, because the ring transition refuses one.

The walk is the same idea as Shimano Synchronized Shift and SRAM AXS Sequential:
rings ascending, cogs descending, one cog per press, and at the end of a ring's
window a jump to the ring above landing on whichever cog gives a step closest to
the cassette step just taken. Research backing the constants: Di2 shift points
are a programmable table rather than a formula, a front shift is always paired
with a one-to-two cog compensating rear shift, and steps below roughly 5% cannot
be felt.

An earlier measurement across the old 616-combination catalogue produced far
uglier numbers, but its worst cases came from 8-speed and triple drivetrains that
have since been removed. Quoting them would have overstated the problem, so the
table above uses real parts only.

### Gear counts

The walk produces **cassette speeds + 3 to + 6** gears, with 60 of the 72 builds
landing on exactly +3 or +4. For 11-speed that is 14 to 16, which matches real
Di2. Wider ring gaps genuinely produce more distinct gears, so `RideabilityTests`
pins that band rather than forcing every build into 14 to 16.

### The parked gear

The bike never shifts, so what the rider feels is the parked ratio multiplied by
the circumference the app sets. The app previously assumed the parked ratio
equalled its own starting gear. `WheelCircumferenceScaler.effectiveCircumference`
already computed `W / referenceRatio x selectedRatio`, so the fix was to pass the
parked ratio in place of the reference. When the two are equal the behaviour is
byte-identical to before, which is why this never showed up as a regression.

The workable parked-ratio window is
`hardestRatio / scaleRange.upperBound ... easiestRatio / scaleRange.lowerBound`.
For the virtual ladder that is 2.011 to 2.50; for the default 105 drivetrain it
is 1.665 to 4.167.
62 changes: 48 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ to know anything about virtual shifting.
Leave the bike in a quiet, straight chain line and shift virtually instead.
Nothing moves on the bike, so shifting is silent and cannot drop the chain.

Because the bike stays in that one gear all ride, Virtual Gears asks you once
which gear it is. That ratio is what every virtual gear is scaled from, so the
app recommends the quietest gear that works and you confirm it in a tap.

**[Read the full documentation](https://sbroenne.github.io/VirtualGears/)**

## Why it exists
Expand Down Expand Up @@ -54,15 +58,22 @@ work but have not been tested.
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.
3. In your riding app, connect to the trainer named **Virtual Gears**. Some apps
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.
4. Tap **Start Shifting**, then shift with the large **Easier** and **Harder**
6. Tap **Start Shifting**, then shift with the large **Easier** and **Harder**
buttons.

There is no setup wizard. Virtual Gears is a transparent trainer connection as
soon as the KICKR is ready. **Start Shifting** engages the 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.
Virtual Gears is a transparent trainer connection as soon as the KICKR is ready.
The physical fact it cannot guess is the gear the bike is parked in, because a
wrong guess would quietly make every gear wrong. **Start Shifting** engages the
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
Virtual Gears discoverable to riding apps on Windows and other computers before
Expand All @@ -80,10 +91,14 @@ with physical hardware.

## What it can do

- **24 ready-made virtual gears**, with extra range for climbing.
- **Ready-made virtual gears**, in ladders of 24 with extra range for climbing.
- **App-independent shifting** for FTMS riding apps that have no virtual gears
of their own.
- **Real-bike gearing**, built from your chainrings and cassette.
- **Real groupsets**, from Shimano, SRAM and Campagnolo, or your own chainrings
and cassette if your bike is not listed.
- **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.
- **Accessible ride controls** with VoiceOver gear feedback, adjustable gear
control and support for larger text.
Expand Down Expand Up @@ -123,13 +138,24 @@ with physical hardware.
<br>
<p align="center">
<a href="docs/screenshots/riding.png">
<img src="docs/screenshots/riding.png" width="28%" alt="Virtual Gears ride screen">
<img src="docs/screenshots/riding.png" width="28%" alt="Virtual Gears ride screen showing gear 12 of 24 and large Easier and Harder controls">
</a>
<a href="docs/screenshots/setup.png">
<img src="docs/screenshots/setup.png" width="28%" alt="First-run setup asking for the physical chainrings and cassette">
</a>
<a href="docs/screenshots/bike-setup.png">
<img src="docs/screenshots/bike-setup.png" width="28%" alt="First-run setup with Zwift Cog or another single sprocket selected">
</a>
</p>
<p align="center">
<a href="docs/screenshots/parked-gear.png">
<img src="docs/screenshots/parked-gear.png" width="28%" alt="Parked-gear recommendation naming the exact chainring and cassette cog">
</a>
<a href="docs/screenshots/gears.png">
<img src="docs/screenshots/gears.png" width="28%" alt="The 24-step virtual gear ladder">
</a>
<a href="docs/screenshots/gears-real-bike.png">
<img src="docs/screenshots/gears-real-bike.png" width="28%" alt="Virtual gears based on a real drivetrain">
<a href="docs/screenshots/headwind-control.png">
<img src="docs/screenshots/headwind-control.png" width="28%" alt="Wahoo Headwind manual fan controls with one-tap speed choices">
</a>
</p>
</details>
Expand All @@ -156,9 +182,17 @@ Every gear change waits for confirmation from the trainer.
- **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
uses the **Normal wheel circumference** saved in Settings, 2070 mm by default,
unless the riding app supplies another size. If you use a custom value in the
Wahoo app, enter the same value in Virtual Gears before shifting.
uses the optional **Wheel circumference** saved in Settings, or the 2105 mm
(700×25 road) default, unless the riding app supplies another size. Common
wheel-size shortcuts and direct millimetre entry are available. If you use a
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
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.

## Support

Expand Down
Loading