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.yaml exactly (snake_case on the wire via the shared JSONCoding encoder/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) and zh-Hans; every key in Localizable.xcstrings has both (scripts/check_strings.py enforces it). Content language is per article/session (LanguageTag); lengths come from TextLength and its server label is rendered verbatim. Requests send Accept-Language = the user's settings.ui_locale override 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 with Text(verbatim:). Localizable.xcstrings is generated from design/strings.json by scripts/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's EXTRA table. scripts/check_strings.py verifies 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) (every Text) and L10n.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 .sensoryFeedback in views (engagement.md); Reduce Motion through \.shukeReduceMotion (system flag or settings.reduce_motion_override).
  • Concurrency. Services are actors; UI-facing models are @MainActor @Observable. Time and sleeping are injected (TimeSource, Sleeper) so tests are deterministic.