A friendly, whimsical pixel-art iOS shopping list. Adding is frictionless (a pinned bottom bar with history-backed suggestions); checking an item off pops a spark burst and an animated strikethrough, then slides it into a faded "Got it" section that clears itself after an hour.
Built with SwiftUI + SwiftData, on-device only (no account, no backend).
The look is a theme (Theme / ThemeStyle): the default is Pastel Dots
— creamy cards on a soft green pixel-dot backdrop, with pixel fonts (VT323 +
Silkscreen) and fresh fruity accents. Other themes ship in code and can be tried
via the BASKET_THEME env var (soft, pixel, dive, cozy, arcade,
fresh1…fresh10).
-
Quick add — always-visible bottom bar with a green + add button; as you type, suggestions float up as one-tap chips: your personal history first (things you've bought in the last month, ranked by frequency + recency), then a built-in food dictionary (
SuggestionDictionary) for instant autocomplete. That dictionary unifies the grocery + regional corpora with the emoji table's whole vocabulary, so anything the app can put an emoji on it can also suggest (e.g. "cord" → Cordial). Items already on the list are filtered out. -
Tap the check circle to check an item off — the check pops with a burst of gold sparks while a strikethrough draws left-to-right, then the row glides into a dimmed "Got it" section (tap it to restore it). Check several off at once and they hold their place until the last spark finishes, then glide down together — so the list never shuffles under your taps.
-
Tap the row (or its faint "+ Qty" chip) to set a quantity. An inline stepper slides down with a smart default unit guessed from the item — pour-y things start in ml, weighed things in g, everything else as a plain count. You can switch units freely from a pill row: every item can be counted in plain "units", and unrecognised items offer the full set (ml/L/g/kg/units), since we can't always know what you mean ("300 ml of milk" vs "1 bottle"). Switching ml↔L or g↔kg keeps the amount; switching to a different kind of unit starts fresh (so it never shows "500 units"). Tap the number itself (between the − and +) to type an exact amount on the keyboard — so a big quantity doesn't mean tapping + over and over. The field is forgiving: it ignores the unit letters ("750 ml" → 750), takes a comma or dot decimal, rounds plain counts to whole numbers, and quietly keeps the old amount if you clear it or type nonsense. The amount shows as a small chip on the row; long names truncate so the chip keeps its place.
-
1-hour TTL on the "Got it" section, so it tidies itself between shops — or tap Clear all in the section header to empty it immediately.
-
Duplicate-aware — re-adding something already listed bumps + flashes the existing row instead of creating a copy.
-
About sheet — the ⓘ in the header opens a small sheet with the app version and an optional tip jar (☕ / 🥪 / 🎁, in-app purchases via StoreKit). Basket is free; tipping unlocks nothing — but once you've tipped, the Basket title turns into a per-letter rainbow with a solid red heart; tap it to toggle between the rainbow and classic looks (your choice persists), as a small thank-you.
-
Little touches — a sub-second basket flourish on cold launch (never on resume), a full-screen "All done!" celebration when you check off the last item, and quiet living details: a faint time-of-day tint and the occasional seasonal accent (🎃, 🎄…) on the empty state.
-
Playful auto-emoji per item, via a three-stage cascade (
Sources/Services/Emoji.swift):- Curated table (
EmojiTable, ~1750 entries, generated bytools/gen_emoji.pyfrom inline data +tools/emoji_supplement.txt) covering produce, proteins, dairy, bakery, grains, pantry, drinks, snacks, frozen, prepared dishes, household/toiletry/baby/pet/health goods, and global cuisines — East/South/Southeast Asian, Middle Eastern, African, Latin American and European staples. The matcher prefers the longest keyword match, so prefix collisions resolve correctly (peach≠pea, ginger≠gin, hamburger≠ham). - Semantic fallback (
SemanticEmoji) — Apple's on-device word embeddings (NLEmbedding, fully offline) map novel items to the nearest "anchor" food word's emoji (e.g. "Flounder" → 🐟). This also collapses variants: "Frozen peas" resolves to the same glyph as "Peas". - Basket default (🧺) when nothing else fits.
Coverage is audited against a ~3650-item global grocery corpus (
tools/corpus/*.txt, spanning world cuisines) withtools/audit_coverage.swift— currently 100% (0 fall-throughs; ~91% curated, ~9% semantic). - Curated table (
-
A warm tri-colour (green/yellow/tomato) background bloom.
-
VoiceOver support — every interactive control (check circle, quantity stepper and unit pills, add bar, tip buttons) has a proper label, hint, and action, and purely decorative flourishes (sparkles, the launch splash, empty state's basket emoji) are hidden from the accessibility tree instead of cluttering it.
Requires Xcode's command-line tools and XcodeGen
(brew install xcodegen).
./build_run.sh # generate → build → install → launch → screenshot
./build_run.sh "iPhone 17" # target a different simulatorbuild_run.sh builds by -target with an explicit SUPPORTED_PLATFORMS because
this machine's Xcode generates a scheme whose supported-platforms list is empty.
Simulator builds need no signing. To run on a physical device, drop your Apple Team ID into a git-ignored override (the committed project stays team-agnostic):
echo 'DEVELOPMENT_TEAM = ABCDE12345' > Signing.local.xcconfig
xcodegen generateSigning.xcconfig (committed) optionally includes Signing.local.xcconfig, so
without the local file the build still works for the simulator.
Coverage is layered like a pyramid — pure logic at the bottom (fast, no simulator), SwiftData/StoreKit/snapshot tests in the middle, full UI flows on top:
-
Tests/— the XCTest suite, run on the simulator:xcodegen generate xcodebuild test -project Basket.xcodeproj -scheme Basket \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro'
This same command also runs
UITests/(below) — both are wired into theBasketscheme's test action, so onexcodebuild testcovers both (with code coverage gathered; view it in Xcode's Report Navigator or viaxcrun xccov). Beyond the pure-logic tests inBasketTests.swift, this target holds the middle layer:ListLogicTests.swift— the section partitioning (to-get / recently-got / TTL-expired) and the check-off spark→commit state machine (CheckOffChoreography), extracted fromShoppingListViewintoServices/ListLogic.swiftprecisely so they're testable without a simulator.ModelTests.swift— SwiftData semantics against an in-memoryModelContainer: the first-launch seed (BasketApp.seedIfEmpty), theKnownItems.rememberAddsuggestion-memory upsert, and theGroceryItem.unitraw-string round-trip.TipJarTests.swift— the tip jar's product loading through a localSKTestSession(StoreKitTest) onStoreKit/Basket.storekit. Purchases themselves can't run in a plain unit-test host (no UI anchor for the confirmation; transaction injection needs a test-plan-level StoreKit config) — see the scope note in the file.SnapshotTests.swift— pixel-level reference images (swift-snapshot-testing) of the core views (ItemRowstates,QuantityEditor, the empty state), stored inTests/__Snapshots__/. Unlike the flow tests' screenshots (review aids), these are assertions — a visual regression fails the build. Recorded on iOS 26.x and skipped (XCTSkip) on other majors, where OS text rendering would diff without a real regression.
-
tools/main.swift— the same source files run natively on macOS (fast, no simulator needed):swiftc Sources/Services/Emoji.swift Sources/Services/EmojiTable.swift \ Sources/Services/SemanticEmoji.swift Sources/Services/Suggestions.swift \ Sources/Services/SuggestionDictionary.swift Sources/Models/Suggestion.swift \ Sources/Services/Formatting.swift \ Sources/Services/Measure.swift Sources/Services/Seasonality.swift \ tools/main.swift -o /tmp/basket_check && /tmp/basket_check -
UITests/— XCUITest flow tests (add an item, suggestions, check one off, restore/clear "Got it", edit quantity, empty state, "All done!" celebration, keyboard dismiss, persistence across relaunch) driving a real simulator through the actual UI, backed by an isolated in-memory SwiftData store (see-uiTesting/-uiTestingEmptyinBasketApp.init; the persistence tests pointUITEST_STORE_URLat their own temp file instead).Tests run deterministically by default (
Services/TestHooks.swift):-uiTestingDisableAnimationsturns off UIKit/SwiftUI animations and shrinks the 0.55s check-commit delay, andUITEST_FROZEN_DATEfreezes the wall clock (an ordinary July morning) so TTL cutoffs, holiday flourishes, and the day-rotating empty-state line render identically on every run — one check-off flow opts back intorealTimingto keep the production choreography covered. Assertions never read live UI state bare: every state check is a bounded wait (waitForLabel/waitForValue/waitForGone/waitForToGetCountinBasketUITestCase), because XCUITest gives no guarantee a tap's effects have rendered by the next line — a bare assert can fail on a slow run or falsely pass on a stale read. Every step attaches a screenshot to the test report (XCTAttachment,.lifetime = .keepAlways), viewable in Xcode's Report Navigator — or export them as plain PNGs:./tools/export_ui_screenshots.sh # → screenshots/ui-tests/ ./tools/export_ui_screenshots.sh "iPhone 17" # target a different simulator
Interactive elements carry stable
.accessibilityIdentifiers (addBar.*,itemRow.*,quantityEditor.*,header.*) so tests query by identifier rather than matching on copy, which is free to change independently.UI tests run against the simulator via the accessibility tree (XCUITest injects synthetic touch events into the simulator process) — they don't drive your physical mouse/trackpad, and can run with no visible Simulator window (
xcrun simctl bootwithout openingSimulator.app).AccessibilityAuditTests.swiftruns XCTest's built-inperformAccessibilityAudit()over the main list (in the default theme and each alternateBASKET_THEME), quantity editor, empty state, and About sheet — catching hit-region/label/trait regressions automatically..contrast,.textClipped, and.dynamicTypeare excluded (with reasons documented inline: the soft/pastel palette and colour emoji don't fit those heuristics, and the pixel fonts are deliberately fixed-size), not silently ignored.TapPrecisionTests.swiftstress-tests the app's smallest controls (the +/- stepper buttons, unit pills, the check circle) with taps offset from dead-center at a fixed, seeded jitter — standing in for a real finger's imprecision, since XCUITest's default tap always lands exactly on center. Every trial is asserted individually; nothing is averaged into a pass-rate threshold, so a real hit-target problem fails the suite instead of being tolerated.
Note:
xcodebuild testand app-icon (asset catalog) compilation require an installed iOS simulator runtime matching the SDK. If you hit "No simulator runtime version … available", runxcodebuild -downloadPlatform iOS.
.github/workflows/ci.yml runs the full suite (simulator tests + native
harness) on every branch push — pre-merge signal for worktree branches,
post-merge backstop on main. On failure it uploads the .xcresult bundle
(which contains the failure screenshots and audit logs) as a workflow
artifact. The merge-gating job never retries: a flaky test should fail
loudly. Instead, a scheduled nightly flake-hunt job runs every test up to
5 times (-test-iterations 5 -run-tests-until-failure) to surface the
only-fails-sometimes kind before it wastes anyone's day.