iOS
Shuke iOS
Native iOS app for Shuke (codename). Swift 6 (strict concurrency, warnings are errors), SwiftUI, iOS 17+, StoreKit 2, Sign in with Apple. The Xcode project is generated by XcodeGen and is not committed.
Foundation (contract models, networking, auth, audio, uploads, StoreKit, persistence, analytics,
notifications) plus the hi-fi UI from design/: onboarding, diagnostic, hard paywall, home, library,
read / record / compress, processing, feedback, settlement, growth, profile & settings. Screenshots
of the key screens in both UI languages live in docs/screenshots/.
Setup
brew install xcodegen # once
cd ios
xcodegen generate # after every pull / when files are added or removed
open Shuke.xcodeproj
Requires Xcode 16+ (developed on Xcode 27.1).
Schemes
| Scheme | What it runs |
|---|---|
Shuke |
Debug build against the API in Config/Debug.xcconfig (http://localhost:8787/v1, i.e. cd backend && pnpm dev). Test action runs ShukeTests + ShukeUITests. Purchases go through RevenueCat (sandbox Apple ID); the scheme's Shuke.storekit only works with RevenueCat once the StoreKit testing certificate is uploaded in RevenueCat → app settings. |
Shuke-Mock |
Same app launched with --mock: fixtures instead of the network, in-memory keychain, simulated microphone, instant uploads. Runs on any simulator with no backend. |
Release builds use Config/Release.xcconfig (placeholder https://api.example.invalid/v1 until a domain is chosen).
API base URL
API_BASE_URL is set per configuration in Config/*.xcconfig and read at runtime from the
ShukeAPIBaseURL Info.plist key. To point a Debug build somewhere else (e.g. a device on your LAN),
create the git-ignored Config/Local.xcconfig:
API_BASE_URL = http:/$()/192.168.1.20:8787/v1
(/$()/ is how xcconfig spells //.) The bundle id lives in one place: APP_BUNDLE_ID in
Config/Shared.xcconfig.
Mock mode
Pass --mock (the Shuke-Mock scheme does this; Debug builds only). MockAPIClient serves the
JSON files in Shuke/Resources/Fixtures through a small stateful MockServer, so the whole funnel
(onboarding → diagnostic → paywall → session → settlement) runs offline. Every fixture has a zh-Hans
(home.json) and an en (home_en.json) variant, picked by the UI locale. Scenario switches:
| Argument | Values |
|---|---|
--mock-user= |
new (onboarding), answered (diagnostic), diagnostic_done (paywall), expired (expired-return paywall), subscribed (default) |
--mock-home= |
normal, first_time, completed, in_progress, no_quest, streak_risk, topic_empty, error |
--mock-growth= |
ready, empty, baseline, gap |
--mock-settlement= |
improved, same, shorter_lost, levelup, replayed, first |
--mock-feedback= / --mock-processing= / --mock-exemplar= |
normal/lowconf/achieved · normal/failed/unreliable/slow · ready/pending/failed |
--mock-mic= |
granted, denied (default: undetermined → purpose sheet) |
--mock-update-required, --fast-clock |
force-update screen · 10× recording clock (UI tests) |
Fixtures are generated from the design track's API-shaped seed data (docs/api/examples/{en,zh-Hans})
by scripts/gen_fixtures.py, which patches them to the current contract, and are validated against
docs/api/openapi.yaml by scripts/validate_fixtures.py (strict: unknown keys fail). Release builds
exclude the fixtures folder (EXCLUDED_SOURCE_FILE_NAMES) and ignore --mock.
python3 -I ios/scripts/gen_fixtures.py ios/Shuke/Resources/Fixtures
uv run --with jsonschema --with pyyaml python ios/scripts/validate_fixtures.py
Tests
cd ios
scripts/ci.sh # xcodegen + build + unit + UI smoke + string catalog check
SIMULATOR="iPhone 16" scripts/ci.sh # pick a simulator (default: first available iPhone)
OS_VERSION=26.2 scripts/ci.sh # pin the simulator OS (default: latest)
scripts/ci.sh build # build only
scripts/ci.sh screenshots # key screens, en + zh-Hans → docs/screenshots/<lang>/*.png
ONLY_TESTING=ShukeTests/PaywallProjectionTests scripts/ci.sh # a subset
or in Xcode: ⌘U on the Shuke scheme. Unit tests run hosted in the app, which detects the test
environment and uses mock services (no network, no keychain). There is no GitHub iOS workflow:
scripts/ci.sh (fixture validator first, then build + tests) is run locally before every PR.
Layout
ios/
project.yml XcodeGen spec (targets Shuke, ShukeTests, ShukeUITests; schemes)
Config/ xcconfigs (bundle id, API base URL per configuration)
Shuke.storekit local StoreKit config for --mock and tests: clarisay.pro.annual (7-day trial), clarisay.pro.monthly
Shuke/
App/ @main, AppDelegate (APNs, background URLSession, BGTask), AppModel (routing), AppDependencies (composition root)
Core/Contract/ Codable mirror of every schema in docs/api/openapi.yaml
Core/API/ Endpoint catalog, APIClient actor, MockAPIClient, errors, paywall signal
Core/Auth/ Keychain, install id + token store, anonymous bootstrap, Sign in with Apple
Core/Audio/ RecordingEngine actor (state machine, segments, resume manifest), AVFoundation backends
Core/Upload/ SHA-256, background-session PUT transport, durable UploadQueue
Core/Processing/ AttemptPoller (poll_interval_ms / poll_timeout_ms → "check later")
Core/Store/ StoreService over a PurchaseProvider (RevenueCat live; plain StoreKit for --mock/tests), entitlement cache + 24 h offline grace
Core/Persistence/ JSON file store (read caches, queues), idempotency keys
Core/Analytics/ D12 event names, sanitizer, batched durable queue → POST /events
Core/Config/ /config with 5-min cache, min-version check
Core/Notifications/ reminder planner (≤1/day, streak-at-risk at 20:00) + scheduler
Core/Support/ logging, clocks, retry policy, locale preference (Accept-Language)
DesignSystem/ Tokens.swift (1:1 with design/tokens.css: light/dark/Increase Contrast), L10n + Format, Components/
Features/ Root, Onboarding, Diagnostic (+ plan), Paywall, Home, Library, Training (read/record/processing/feedback/complete), Growth, Profile
Resources/ Localizable.xcstrings, InfoPlist.xcstrings, assets, Fixtures/
ShukeTests/ unit tests
ShukeUITests/ --mock flows (onboarding → paywall, home → complete) + screenshot capture
scripts/ ci.sh, fixture generator/validator, string catalog generator/check
Conventions
- Contract first. Models mirror
docs/api/openapi.yamlexactly (snake_case on the wire via the sharedJSONCodingencoder/decoder; RFC 3339 dates with or without fractional seconds). Server enums decode unknown values to.unknown; open sets (APIErrorCode,LanguageTag) keep the raw string. - Languages (ADR-001 D13). UI locales
en(development language) andzh-Hans; every key inLocalizable.xcstringshas both (scripts/check_strings.pyenforces it). Content language is per article/session (LanguageTag); lengths come fromTextLengthand its serverlabelis rendered verbatim. Requests sendAccept-Language= the user'ssettings.ui_localeoverride or the app's current localization. Nothing branches on a specific language. - No hard-coded copy in views — keys only (
Text("tab.home"),Text("common.xp_plus \(xp)")); server strings are rendered withText(verbatim:).Localizable.xcstringsis generated fromdesign/strings.jsonbyscripts/gen_strings.py: keys keep the design names, and keys with arguments get their format specifiers appended (common.xp_plus→common.xp_plus %lld) so SwiftUI interpolation resolves them. App-only strings live in the script'sEXTRAtable.scripts/check_strings.pyverifies every compiler-extracted key and every literal runtime key (L10n.string,L10n.format,.dynamic) exists in both languages. - In-app UI language (
settings.ui_locale) is applied through.environment(\.locale)(everyText) andL10n.uiLocale(runtime lookups); changing it invalidates the API-string caches. - Design tokens only —
Tokens.Color.*,Tokens.Font.shuke(_:),Tokens.Spacing.s*,Tokens.Radius.*,Tokens.Motion.*; haptics via.sensoryFeedbackin views (engagement.md); Reduce Motion through\.shukeReduceMotion(system flag orsettings.reduce_motion_override). - Concurrency. Services are actors; UI-facing models are
@MainActor @Observable. Time and sleeping are injected (TimeSource,Sleeper) so tests are deterministic.