Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 31 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ jobs:
echo "Using: $name"
echo "name=$name" >> "$GITHUB_OUTPUT"

# Deliberately does NOT pass CODE_SIGNING_ALLOWED=NO. An unsigned app has
# no application-identifier entitlement, so the simulator keychain answers
# errSecMissingEntitlement and the session-persistence tests skip instead
# of running — the auth suite would report green while never touching the
# keychain. Simulator builds sign ad-hoc without a team, so this works on
# a bare runner.
- name: Test
if: steps.probe.outputs.found == 'true'
run: |
Expand All @@ -133,5 +139,28 @@ jobs:
-scheme "$SCHEME" \
-destination "platform=iOS Simulator,name=${{ steps.sim.outputs.name }}" \
-derivedDataPath build/dd \
CODE_SIGNING_ALLOWED=NO \
test
test 2>&1 | tee test.log

# The build job's gate only compiles the app target, so warnings in the
# test target were invisible to CI until a clean build surfaced them.
- name: Fail on Swift warnings in the test target
if: steps.probe.outputs.found == 'true'
run: |
count=$(grep -cE "\.swift.*warning:" test.log || true)
echo "Swift warnings: $count"
if [ "$count" -ne 0 ]; then
grep -E "\.swift.*warning:" test.log | sort -u
exit 1
fi

# A suite that skips its security tests must not read as a pass.
- name: Fail if security tests were skipped
if: steps.probe.outputs.found == 'true'
run: |
skipped=$(grep -cE "skipped:" test.log || true)
echo "skipped tests: $skipped"
if [ "$skipped" -ne 0 ]; then
grep -E "skipped:" test.log | sort -u
echo "::error::Tests skipped — the keychain-backed auth tests did not run"
exit 1
fi
128 changes: 128 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Vinnota — developer notes

Native iOS app built from the Claude Design project
`Vinnota - Cellar Book.dc.html`.

SwiftUI · iOS 17+ · SwiftData · Vision · Speech · AVFoundation · Swift Testing

```bash
open Vinnota.xcodeproj
```

```bash
xcodebuild -scheme Vinnota -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build
```

```bash
xcodebuild test -scheme Vinnota -destination 'platform=iOS Simulator,name=iPhone 17 Pro'
```

## Repository conventions

**`main` is protected.** Work on a feature branch and open a PR; direct pushes
are rejected.

**Commit messages** are enforced by a committed hook. Enable it once per clone:

```bash
git config core.hooksPath .githooks
```

It requires a Conventional Commits subject of at most 100 characters, on a
**single line with no body**, and **no `Co-Authored-By` trailer**. Accepted
types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.

**CI** (`.github/workflows/ci.yml`) builds Debug and Release, fails on any Swift
warning, runs the test suite, and asserts the debug sign-in stub is absent from
the Release binary. It is explicitly `contents: read` and publishes nothing.
Actions are pinned to commit SHAs; Dependabot opens one grouped PR a month.

## Layout

```
Vinnota/
Theme/ Palette, Typography
Model/ Wine, TastingNote, enums, AppState, Settings, Formatters
Services/ AuthController, CameraController, LabelScanner, SpeechTranscriber
Views/ one file per screen, Sheets/, Components/
Resources/ bundled fonts
VinnotaTests/ Swift Testing suites
```

`Vinnota.xcodeproj` uses file-system-synchronized groups (`objectVersion 77`),
so adding a Swift file to `Vinnota/` or `VinnotaTests/` is enough — no project
edit needed.

## The screens

Seven states on one surface, mirroring the design's single `screen` variable
rather than a navigation stack — every screen paints its own chrome.

| Screen | File | What it does |
|---|---|---|
| Login | `LoginView` | Sign in with Apple, with a local stub fallback |
| Cellar | `CellarView` | Two-column grid, three stats, six filter tabs |
| Search | `SearchView` | Live filter over producer, cuvée, region, grape, shop |
| Scan | `ScanView` | Live camera + Vision OCR, photo-library fallback |
| Review | `ReviewView` | Correct what OCR read, then file the bottle |
| Detail | `DetailView` | Hero, facts, provenance timeline, notes |
| Tasting | `TastingView` | Photograph the pour, note it, pick a verdict |

Plus four overlays: note composer, currency picker, purchase, and delete
confirmation — with a toast for confirmations.

## The model

A bottle moves `new → want / maybe / not → bought → tasted`. Prices are dual:
the shelf price seen when scanned, and what was actually paid. Notes are split
`pre` (before opening) and `post` (in the glass), each either typed or
dictated.

Only the **producer** gates a save; everything else is optional and can be
filled in later from the edit screen. The reasoning is in OPEN-QUESTIONS §3.6a
— a form filled in a shop aisle that blocks on missing data produces no data,
not better data.

The form's commit path lives on `WineForm` (`makeWine`, `apply(to:)`,
`makeNotes`) rather than inside `ReviewView.save()`, so it is reachable from
tests. It was inlined once, and a mutation dropping `.trimmed` went undetected.

## What is real, not simulated

The design fakes its scanner (a 1.6s delay and canned text) and its
transcription. Both are real here:

- **`LabelScanner`** — `VNRecognizeTextRequest` at `.accurate`, five languages,
language correction off since labels are proper nouns. Fields are assigned by
heuristic: the tallest text is the producer, a four-digit year in range is the
vintage, and grape/region are matched against built-in lists. `parse(_:)` is
pure and directly unit-tested.
- **`SpeechTranscriber`** — `SFSpeechRecognizer`, preferring an on-device model
where one exists and otherwise falling back to Apple's servers. The waveform
is driven by real RMS levels off the audio buffer, not an animation.
- **`CameraController`** — `AVCaptureSession` with continuous autofocus. The
simulator has no camera, so `isAvailable` is false there and the scan screen
offers `PhotosPicker` into the identical OCR path.

## Design fidelity

Colours, type sizes, tracking, spacing and radii are transcribed from the
`.dc.html` rather than approximated. The palette lives in `Theme/Palette.swift`
with the source values in comments.

Instrument Sans ships as a **variable** font, and iOS will not interpolate a
variable axis — `UIFont(name:size:)` always returns the default instance. So
weights are produced by driving the `wght` axis through CoreText
(`Theme/Typography.swift`). Instrument Serif is bundled as static regular and
italic faces from Google Fonts (OFL).

## Unverified for want of hardware

- **The camera path.** No camera on the development host, so scanning is tested
through the photo-library fallback — same OCR code, different image source.
CI runners have no camera either, so this gap is not closed by CI.
- **Dictation.** The Simulator cannot open an audio input on this virtualised
host, so `SpeechTranscriber` refuses there rather than letting AudioToolbox
abort the process.

`TESTING.md` lists what to exercise on a real device.
150 changes: 58 additions & 92 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,106 +1,72 @@
# Vinnota — Cellar Book
# Cellar Book

A native iOS app built from the Claude Design project
`Vinnota - Cellar Book.dc.html`. Scan a wine label on the shelf, keep the note,
record what the bottle did in the glass.
A wine notebook for your phone. Point it at a label in the shop, keep the
bottle, and remember what it was actually like when you opened it.

SwiftUI · iOS 17+ · SwiftData · Vision · Speech · AVFoundation
Made for the moment you are standing in an aisle holding something you have
never heard of, trying to decide.

---

## Status
## What it does

**Builds and runs.** Verified on Xcode 26.6 / iOS 26.5, iPhone 17 Pro
simulator, 2026-09-05. The full flow was exercised: sign in, scan a label
through Vision OCR, correct and file it, set keenness, record a purchase,
taste it with a verdict, search, and delete.
**Scan a label.** Photograph the bottle and the producer, year, grape and
region are read off it for you. Correct anything it got wrong — labels are
hard to read, and it will not always get them right.

Two things are **not** verified, both for want of hardware:
- **The camera path.** No camera on this host, so scanning was tested through
the photo-library fallback — same OCR code, different image source.
- **Dictation.** The Simulator cannot open an audio input on this virtualised
host, so `SpeechTranscriber` refuses there rather than letting AudioToolbox
abort the process. Needs a real device.
**Or just type it in.** No camera, no signal, no patience: enter the name and
you are done. Everything else can wait, and you can add a label photo later.

See [OPEN-QUESTIONS.md](OPEN-QUESTIONS.md) for both, plus the missing login
photograph and the decisions taken along the way.
**Say how keen you are.** Want to try, undecided, or pass. The passes matter as
much as the wants — that is how you stop buying the same disappointing bottle
twice.

```bash
open Vinnota.xcodeproj
```
**Keep a note.** Type it, or dictate it if your hands are full. What the
shopkeeper said, who recommended it, why you picked it up.

Or from the command line:
**Record the bottle you bought.** What you paid, how many, and where — which is
rarely what the shelf said.

```bash
xcodebuild -scheme Vinnota -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build
```
**Taste it.** Photograph the glass, write what it was like, and give it a
verdict. Loved, fine, or no.

**Find it again.** Search across producer, cuvée, region, grape and shop, or
filter the shelf by where each bottle has got to.

## Your cellar stays on your phone

The book is stored on your device. There is no account to create beyond signing
in, nothing is uploaded, and no one else can see what you drink.

Two things to know:

- **It is not backed up anywhere by us.** Your cellar rides along in your normal
encrypted iPhone backup. Without one, losing the phone loses the book.
- **Dictation uses Apple's speech recognition.** On many phones that happens on
the device; where it cannot, Apple transcribes it. Either way the finished
note is kept on your phone and nowhere else.

## Getting it running

Requires an iPhone or iPad on **iOS 17 or later**.

Open `Vinnota.xcodeproj` in Xcode, pick your device, and press run. Building to
a real iPhone needs a free Apple ID — [TESTING.md](TESTING.md) walks through the
signing set-up and what to try once it is installed.

## Known gaps

Being straight about what is not there yet:

- **No export.** The only copy is on the phone.
- **The camera and dictation are unverified on real hardware.** They are written
and wired up, but the development machine has neither, so they have only been
exercised through the photo library and a simulator.
- **One cellar per device.** Signing out leaves the bottles behind, so a second
person on the same phone sees the first person's book.
- **English only, and no accessibility work yet** — text does not respond to
Larger Text, and there are no VoiceOver labels.

---

## The screens

Seven states on one surface, mirroring the design's single `screen` variable
rather than a navigation stack — every screen paints its own chrome.

| Screen | File | What it does |
|---|---|---|
| Login | `LoginView` | Sign in with Apple, with a local stub fallback |
| Cellar | `CellarView` | Two-column grid, three stats, six filter tabs |
| Search | `SearchView` | Live filter over producer, cuvée, region, grape, shop |
| Scan | `ScanView` | Live camera + Vision OCR, photo-library fallback |
| Review | `ReviewView` | Correct what OCR read, then file the bottle |
| Detail | `DetailView` | Hero, facts, provenance timeline, notes |
| Tasting | `TastingView` | Photograph the pour, note it, pick a verdict |

Plus four overlays: dictation, currency picker, purchase, and delete
confirmation — with a toast for confirmations.

## The model

A bottle moves `new → want / maybe / not → bought → tasted`. Prices are dual:
the shelf price seen when scanned, and what was actually paid. Notes are split
`pre` (before opening) and `post` (in the glass), each either typed or
dictated. Tasted bottles cannot be deleted — they stay on the record.

## What is real, not simulated

The design fakes its scanner (a 1.6s delay and canned text) and its
transcription. Both are real here:

- **`LabelScanner`** — `VNRecognizeTextRequest` at `.accurate`, five languages,
language correction off since labels are proper nouns. Fields are assigned by
heuristic: the tallest text is the producer, a four-digit year in range is the
vintage, and grape/region are matched against built-in lists. Boilerplate
("contains sulfites", "75cl", appellation legalese) is filtered out.
- **`SpeechTranscriber`** — `SFSpeechRecognizer` with
`requiresOnDeviceRecognition`, honouring the design's on-device promise. The
waveform is driven by real RMS levels off the audio buffer, not an animation.
- **`CameraController`** — `AVCaptureSession` with continuous autofocus. The
simulator has no camera, so `isAvailable` is false there and the scan screen
offers `PhotosPicker` into the identical OCR path.

## Design fidelity

Colours, type sizes, tracking, spacing and radii are transcribed from the
`.dc.html` rather than approximated. The palette lives in `Theme/Palette.swift`
with the source values in comments.

Instrument Sans ships as a **variable** font, and iOS will not interpolate a
variable axis — `UIFont(name:size:)` always returns the default instance. So
weights are produced by driving the `wght` axis through CoreText
(`Theme/Typography.swift`). Instrument Serif is bundled as static regular and
italic faces from Google Fonts (OFL).

## Layout

```
Vinnota/
Theme/ Palette, Typography
Model/ Wine, TastingNote, enums, AppState, Settings, Formatters
Services/ AuthController, CameraController, LabelScanner, SpeechTranscriber
Views/ one file per screen, Sheets/, Components/
Resources/ bundled fonts
```

`Vinnota.xcodeproj` uses a file-system-synchronized group (`objectVersion 77`),
so adding a Swift file to `Vinnota/` is enough — no project edit needed.
Contributing or looking at the code? See [CLAUDE.md](CLAUDE.md).
Loading