Files

101 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<T>` 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 upstream; **203 MB in this git copy**, which keeps only the two iOS slices — see below) 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 = "<group>";
```
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(<json string>)`. 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 <usertoken>`. **`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<CGImage>` (`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.