# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Overview XuXuCheck is an iOS SwiftUI app (bundle id `com.niaodaifu.xuxu.XuXuCheck`) that analyzes dry-chemistry urinalysis test strips: the user photographs a strip with the camera, the app locates the strip with a C++/OpenCV pipeline, samples 16 reagent pads over 16 frames, and posts the HSV/Lab measurements to a remote API. Everything else in the app (product info, records, members, account) is server-rendered web content shown in a `WKWebView`. The app UI is in English, but the business domain and the original C++ pipeline are Chinese (comments in `XuXuCheck/opencv/`). String catalog `XuXuCheck/Localizable.xcstrings` ships `en` + `zh-Hans`; always route user-facing text through `localizeString(_:)` (`utils/StringExts.swift`), never a bare literal. ## Build / run Always use the **workspace**, not the `.xcodeproj` — the app links the CocoaPods framework. ```bash open XuXuCheck.xcworkspace # or: xcodebuild -workspace XuXuCheck.xcworkspace -scheme XuXuCheck \ -destination 'generic/platform=iOS' build xcodebuild -workspace XuXuCheck.xcworkspace -scheme XuXuCheck \ -destination 'platform=iOS Simulator,name=iPhone 17' build ``` - `pod install` after changing `Podfile`/`Podfile.lock` (currently CocoaPods 1.16.2; Alamofire **4.x**, not 5.x, so `Alamofire.request(...)` and `DataResponse` are correct here). - **The `Podfile` ends with a `post_install` hook that is load-bearing.** It forces every pod target's `IPHONEOS_DEPLOYMENT_TARGET` to 15.6. The podspecs declare 8.0 and `platform :ios` used to say 13.0; Xcode 27 only accepts 15.0–27.0, so without the hook `pod install` produces a project that fails **before compiling anything** (`The iOS deployment target 'IPHONEOS_DEPLOYMENT_TARGET' is set to 8.0, but the range of supported deployment target versions is 15.0 to 27.0.x`). This was previously a hand-edit to 22 lines of `Pods.xcodeproj` and was silently reverted by every regeneration — i.e. any `pod install` used to break the build. Don't move it back into the project file. - Only three pods remain: `Alamofire`, `AlamofireObjectMapper` and its transitive `ObjectMapper`. `ProgressHUD`, `SDWebImage`/`SDWebImageSwiftUI` and `WrappingHStack` were removed — no app source ever imported them, yet `use_frameworks!` was embedding all of them as dynamic frameworks (3.7 MB of the 6.4 MB in `XuXuCheck.app/Frameworks`). Check `XuXuCheck.app/Frameworks` after touching this: 3 frameworks, ~2.8 MB. - The test targets are empty Xcode stubs (`XuXuCheckTests` uses Swift Testing, `XuXuCheckUITests` uses XCTest). There is no meaningful test suite yet; run a single one with `-only-testing:XuXuCheckTests/XuXuCheckTests/example`. - A physical device is required to exercise the camera/scan flow; the simulator will show the empty camera branch. ### The OpenCV framework is vendored in the repo `Frameworks/opencv2.xcframework` (309 MB) is checked into the working directory, so a plain checkout builds without anything external. It is wired up by exactly one `PBXFileReference` in `XuXuCheck.xcodeproj/project.pbxproj`: ``` path = Frameworks/opencv2.xcframework; sourceTree = ""; ``` resolved from the project directory (the `Frameworks` group has no `path`, so it contributes nothing). There are no `FRAMEWORK_SEARCH_PATHS` / `HEADER_SEARCH_PATHS` anywhere — at build time Xcode's `ProcessXCFramework` copies the matching slice into DerivedData and both the compiler and linker find it through the automatic `-F$(BUILT_PRODUCTS_DIR)`. So moving the bundle means editing that one line and nothing else. Upstream facts worth knowing before touching it: - All four slices are **static** (`current ar archive`), so nothing is embedded and there is no Embed phase. Two of them are visionOS (`xros-arm64`, `xros-arm64-simulator`, ~106 MB combined) and are dead weight for this iOS-only target. **In this git copy those two visionOS slices have been deleted** — together with their `AvailableLibraries` entries in the xcframework's `Info.plist` — leaving only `ios-arm64` and `ios-arm64_x86_64-simulator`; `ProcessXCFramework --platform ios` was verified against the edited plist by a build. The desktop copy still has all four. - Nothing in this repo builds it. It was produced outside, at `~/opencv/build_all/`, and the same file is also linked by `~/Desktop/cvgithub/OpenCVLightBuzz/OpenCV-Apple-Mutliplatform` — keep a copy there if that project still matters, or refresh both when OpenCV is rebuilt. - `~/Downloads/opencv2.framework` is unrelated: the raw `opencv-4.11.0-ios-framework.zip` unpacked, a 564 MB fat binary containing `armv7 armv7s i386 x86_64 arm64` with no separate simulator slice. Do not repoint the project at it. - Debug builds split the real code into `XuXuCheck.debug.dylib` (Xcode 16+ debug-dylib), leaving a ~90 KB executable stub. Judge binary size from the `.dylib`, not the executable. Deployment target is iOS 18.4 with `CLANG_CXX_LANGUAGE_STANDARD = gnu++20` (required by `opencv/ImgLocateDryChemistry.cpp`); the `Podfile`'s `platform :ios, '15.6'` only applies to the pods — it is there to clear Xcode 27's 15.0 floor, see the `post_install` note above. ## Architecture ### App state: Observation framework, not ObservableObject `XuXuCheckApp.swift` builds the whole object graph as `@State` and injects it with `.environment(...)`: `Status` (nav chrome), `LoginViewModel`, `CameraViewModel`, `LoginStatus`, `WebViewModel`. Read them in views with `@Environment(Type.self) var vm` / `@Bindable` — there are no `@EnvironmentObject`/`ObservableObject`/`@Published` anywhere. When adding shared state, follow this pattern (a `@Observable` class + `.environment()` + `@Environment(X.self)`). `Storage.shared` (`utils/Storage.swift`) is a separate singleton wrapper over `@AppStorage` UserDefaults keys (token, login flag, active member) — it is the source of truth for auth and is read imperatively from non-View code (Router, bridge handlers), so it is intentionally *not* part of the environment graph. ### Navigation `ContentView` switches on `@AppStorage("selectedTab")` over the `Tab` enum (`navigation/Tab.swift`) and overlays `TabBar`. `Status.showNavDetail` controls the tab bar and its name is **inverted** — `true` means "a detail page is showing", which `ContentView` renders as `TabBar().offset(y: 200)`, i.e. off screen. `XuXuWebView` pairs `onAppear` (true) with `onDisappear` (false) so the bar comes back however the page is left; the `goback` and `logout` web commands also reset it inline so it returns together with the dismiss animation. Both halves are required — resetting from only one exit path is what caused the "log out from Settings and the tab bar never comes back" bug. Tabs: Home / Members / Records / Mine; Members and Records fall back to `LoginView` when `loginStatus.isLogin == false`. `TabBarView.swift` is an older `TabView`-based variant that is not used by the current `ContentView`. ### Hybrid web/native split Most feature screens are remote web pages, not SwiftUI. `Home.swift` lists server URLs and `api/API.swift` holds the base URLs (`https://xuxucheck.com/web/...`) plus the `?channle=...&platform=1&usertoken=...` query string built by `GetGeneralRequestParams()` in `utils/Globe.swift`. - Rendering: `XuXuWebView` → `WebViewRepresentable` (a `WKWebView` `UIViewRepresentable`) → `WebViewModel`, which owns the URL, loading state, and JS evaluation. - **Web → Swift**: the page calls `window.webkit.messageHandlers.receiveData.postMessage()`. The JSON decodes into `JS2SwiftModel { command, parameter }` (`Model/JS2SwiftModel.swift`). A command is handled in **two places, and both are required**: - `WebViewRepresentable.Coordinator.userContentController` — mutates `Storage`/app state (`setdefaultmember`, `changeself`, `logout`, `login`). - `XuXuWebView.onChange(of: webViewModel.hasNewCommand)` — drives SwiftUI presentation/dismissal (`goback`, `scancode`, `selectphoto`, `selectcamera`, `scanpaper`). Adding a command means editing both switches (and `JS2SwiftData` if it carries new payload). - **Swift → Web**: `WebViewModel.callJsImage` / `callJsBarcode` invoke a global JS function on the page: `CallJS('SelectPhoto'|'BarCode', payload)`. Photos are sent as a JPEG base64 data URL. - **Vertical edges**: `XuXuWebView` renders web views full-bleed at the bottom and with a small app-side inset at the top (`topSafeInset`, default 20), paired with `contentInsetAdjustmentBehavior = .never` in `WebViewRepresentable`. The pages' own headers already leave ~49pt for a browser status bar, so the old full safe-area inset doubled up into an oversized blue band; conversely the web view must reach the physical bottom or the parent view's white shows through as a 34pt band. All three pieces are one mechanism and affect every web page at once. Measurements, tuning table and how to sample screenshots: `docs/webview-safe-area.md`. - Local HTML (`Web_Assets/index.html`, `css/`, `images/`) is leftover scaffolding from the upstream WKWebView sample and is only loaded when `WebViewModel.url` is nil; ignore it when tracing real behavior. Note the sample handler name there (`MyObserver`) differs from the one the bridge actually registers (`receiveData`). ### Networking `api/Router.swift` is an `Alamofire` 4 `URLRequestConvertible` enum — one case per endpoint, with `params`, `path`, and `method` all in the `switch`. `api/APIService.swift` is the singleton facade with `(Model?, XuXuRespError?)` completion handlers, mapping via `AlamofireObjectMapper` (`MemberModel`, `RecordResultModel` are `Mappable` classes; `RecordModel` is `Codable`). Conventions worth preserving: - `model.code > 0` means a business error; it is surfaced as `XuXuRespError(code:message:)`. Transport/HTTP failures are normalized to `code == 10086`. - Every request carries `X-Platform`, `X-Channel`, `X-Time` (unix seconds), `X-Sign`, `X-TimeZone`, `X-Locale`, and, when logged in, `Authorization: Bearer `. **`X-Sign` is derived from `X-Time`** by `getSign(time:)` in `utils/Globe.swift` (MD5 slices of a hardcoded salt + the timestamp). If you change how time is generated, signing breaks. - Most endpoints send form-encoded params (`URLEncoding.default`); `interface_result_addrecord` is the exception — it builds a snake_case JSON `RecordModel` body with `JSONEncoding.default` and base64-free `"v"` strings of comma-joined measurements. ### Camera + OpenCV scan pipeline Flow: `HomeView.HomeScanView` or a web command → `ScanView` → `TimerCountView` (countdown) → `CameraView`. - `CameraManager` runs an `AVCaptureSession` and exposes frames as `AsyncStream` (`previewStream`); `CameraViewModel` pumps it into `currentFrame` on the main actor. `CameraViewModel` also owns the accumulated `array3D` and the upload call. - **`CameraView`'s body is the processing loop**: every re-render calls `OpenCVWrapper.analysisImage(uiImage)` inline and inspects the result. Work is gated on `result.paperWbValue[3] == 0` (a valid strip/locator was found); measurements accumulate one frame at a time until 16 frames are collected, then `addRecord` is called. Any change to this view's structure changes how often the pipeline runs and how fast the 16 frames fill. - `opencv/OpencvWrapper.mm` is Objective-C++ bridging to the C++ `ImageLocation` / `ImgLocateDryChemistry` locator, which finds the strip from its colored locator blocks (`ContourSelector`, `ContourGf*`). Only the wrapper header is exposed to Swift, via `XuXuCheck-Bridging-Header.h` — note it imports `opencv/OpenCVWrapper.h` while the file on disk is `OpencvWrapper.h`; this resolves only because macOS filesystems are case-insensitive, so keep the name if you want it to keep compiling. - The bridge returns an `ImgResult` Objective-C object with untyped `NSMutableArray` properties, which Swift force-casts (`result.paperAnalResults as! [[Double]]`). The shapes are fixed: `paperWbValue` = `[locatorColor, items, locatorLength, validFlag]`, `paperTypeDesc` = same 4 slots as Ints, `paperAnalResults` = `16 × [H, S, V, L, a, b]`. Those casts crash if the C++ side changes a dimension — update `ImgResult.m`'s defaults too. ## Notes and quirks - `Color(hex:)` exists twice: `DynamicColor/` is a vendored copy of yannickl/DynamicColor supplying `Color(hex: UInt64)` (used as `Color(hex: 0x2449f1)`); `utils/exts.swift` adds a separate `Color(hex: String)`. Check the argument type before assuming which one resolves. - `Model/JS2SwiftModel.swift` maps the JS key `"self"` onto a property deliberately named `jsSelf` — the file comments explain that naming it `selfData` broke compilation. - `XuXuCheck.entitlements` contains macOS-style sandbox keys, which have no effect on the iOS target. - Comments and code in this repo are a mix of the original author's notes, upstream sample code, and dead/commented-out blocks (notably in `ContentView.swift`, `CameraView.swift`, and `WebViewRepresentable.swift`). Read the live code path before trusting a commented alternative.