导入 XuXuCheck 工程(iOS 干化学尿液试纸分析 App)

从桌面工程复制而来,作为本仓库的 Xuxucheck/ 子目录。

为控制仓库体积做了两处删减:
- 移除 Frameworks/opencv2.xcframework 下的两个 visionOS 切片
  (xros-arm64、xros-arm64-simulator,合计约 106 MB),并同步删掉该
  xcframework 内 Info.plist 里对应的两条 AvailableLibraries 记录,
  只保留 ios-arm64 与 ios-arm64_x86_64-simulator。
- 移除全部 .DS_Store 与 xcuserdata/。

已用 xcodebuild 从本目录完整编译验证:BUILD SUCCEEDED,
ProcessXCFramework 以 --platform ios 成功选中框架,产物含 3699 个 cv:: 符号
(OpenCV 确实链入),嵌入 framework 3 个,与移除无用 pod 之后的状态一致。

仓库约 206 MB,其中 OpenCV 203 MB、其余源码与依赖约 3 MB。
This commit is contained in:
2026-09-23 14:17:31 +08:00
commit d6aeede2bc
901 changed files with 327267 additions and 0 deletions
@@ -0,0 +1,146 @@
//
// CameraManager.swift
// XuXuCheck
//
// Created by henseycai on 2025/6/25.
//
import AVFoundation
import CoreImage
class CameraManager: NSObject {
private let captureSession = AVCaptureSession()
private var deviceInput: AVCaptureDeviceInput?
private var videoOutput: AVCaptureVideoDataOutput?
private let systemPreferredCamera = AVCaptureDevice.default(for: .video)
private var sessionQueue = DispatchQueue(label: "video.preview.session")
private var addToPreviewStream: ((CGImage) -> Void)?
lazy var previewStream: AsyncStream<CGImage> = {
AsyncStream { continuation in
addToPreviewStream = { cgImage in
continuation.yield(cgImage)
}
}
}()
private var isAuthorized: Bool {
get async {
let status = AVCaptureDevice.authorizationStatus(for: .video)
// Determine if the user previously authorized camera access.
var isAuthorized = status == .authorized
// If the system hasn't determined the user's authorization status,
// explicitly prompt them for approval.
if status == .notDetermined {
isAuthorized = await AVCaptureDevice.requestAccess(for: .video)
}
return isAuthorized
}
}
override init() {
super.init()
Task {
await configureSession()
//await startSession()
}
}
public func StartCapture(){
Task{
await startSession()
}
}
public func StopCapture(){
Task{
await stopSession()
}
}
private func startSession() async {
guard await isAuthorized else { return }
captureSession.startRunning()
}
private func stopSession() async {
guard await isAuthorized else { return }
captureSession.stopRunning()
}
private func configureSession() async {
guard await isAuthorized,
let systemPreferredCamera,
let deviceInput = try? AVCaptureDeviceInput(device: systemPreferredCamera)
else { return }
captureSession.beginConfiguration()
defer {
self.captureSession.commitConfiguration()
}
let videoOutput = AVCaptureVideoDataOutput()
videoOutput.setSampleBufferDelegate(self, queue: sessionQueue)
guard captureSession.canAddInput(deviceInput) else {
return
}
guard captureSession.canAddOutput(videoOutput) else {
return
}
captureSession.addInput(deviceInput)
captureSession.addOutput(videoOutput)
//For a vertical orientation of the camera stream
videoOutput.connection(with: .video)?.videoRotationAngle = 90
}
private func rotate(by angle: CGFloat, from connection: AVCaptureConnection) {
guard connection.isVideoRotationAngleSupported(angle) else { return }
connection.videoRotationAngle = angle
}
}
extension CameraManager: AVCaptureVideoDataOutputSampleBufferDelegate {
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
guard let currentFrame = sampleBuffer.cgImage else {
print("Can't translate to CGImage")
return
}
addToPreviewStream?(currentFrame)
}
}
extension CMSampleBuffer {
var cgImage: CGImage? {
let pixelBuffer: CVPixelBuffer? = CMSampleBufferGetImageBuffer(self)
guard let imagePixelBuffer = pixelBuffer else { return nil }
return CIImage(cvPixelBuffer: imagePixelBuffer).cgImage
}
}
extension CIImage {
var cgImage: CGImage? {
let ciContext = CIContext()
guard let cgImage = ciContext.createCGImage(self, from: self.extent) else { return nil }
return cgImage
}
}
+230
View File
@@ -0,0 +1,230 @@
//
// CameraView.swift
// XuXuCheck
//
// Created by henseycai on 2025/6/25.
//
import SwiftUI
struct CameraView: View {
// @Binding var image: CGImage?
// @Binding var array3D: [[[Double]]]
@Environment(CameraViewModel.self) private var cameraViewModel
@Environment(\.dismiss) private var dissmiss
//https://cloverlaun.tistory.com/77
//https://stackoverflow.com/questions/59438285/disable-swipe-back-for-a-navigationlink-swiftui/65354662
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
@State private var showingNaiveSafariViewFullScreenCover = false
@State var isAnimating = false
//let grayImage = OpenCVWrapper.toGrayscale(uiImage)
var body: some View {
ZStack (alignment: .top){
GeometryReader { geometry in
if let image = cameraViewModel.currentFrame {
let uiImage = UIImage(cgImage: image)
let result = OpenCVWrapper.analysisImage(uiImage)
let wbArr = result.paperWbValue as! [Double]
// let descArr = result.paperTypeDesc as! [Int]
// let wbArr = result.paperWbValue as! [Int]
if(wbArr[3] == 0){
if(cameraViewModel.array3D.count < 16){
let _ = cameraViewModel.array3D.append(result.paperAnalResults as! [[Double]])
// let _ = print(cameraViewModel.array3D.count)
}else{
if(cameraViewModel.isDataSent == false){
let _ = print(cameraViewModel.array3D)
//let _ = cameraViewModel.stopSession()
let _ = cameraViewModel.addRecord(desc: result.paperTypeDesc as! [Int])
}
//following line works just bcs Image come continuously ,so following triggered. thanks God~
// if(cameraViewModel.isSuccess == true){
// let _ = self.presentationMode.wrappedValue.dismiss()
//
// XuXuWebView(currentUrl: cameraViewModel.recordDetailUrl())
// }
}
//let _ = print(wbArr)
// let _ = print(descArr)
}
Image(uiImage: result.srcImg)
.resizable()
.scaledToFit()
.frame(width: geometry.size.width,
height: geometry.size.height)
//.clipped()
/*clipped() This crucial modifier crops any content of the view that extends beyond its defined frame. When combined with .scaledToFill() and .frame(), it effectively "crops" the image to the specified dimensions.*/
Image("scanfan").resizable().frame(width: 60,height: 235)
.offset(x: isAnimating ? geometry.size.width : -50,y:70)
.animation(.easeInOut(duration: 5).repeatForever(autoreverses: false), value: isAnimating)
.onAppear{
isAnimating.toggle()
}
Text("Scan urinal strip in natural light. Put strip on white paper towel.Ensure strip is completely presented in green box.").foregroundStyle(Color.white)
.font(.title2).frame(maxWidth: .infinity, maxHeight:.infinity,alignment: .topLeading)
.padding().background(Color.gray)
.offset(x:0,y:400)
} else {
// ContentUnavailableView("Camera feed interrupted", systemImage: "xmark.circle.fill")
// .frame(width: geometry.size.width,
// height: geometry.size.height)
}
}
.padding(.top,20)
.onChange(of: cameraViewModel.isSuccess){oldState,newState in
if(oldState == false && newState == true){
//https://www.hackingwithswift.com/quick-start/swiftui/how-to-run-some-code-when-state-changes-using-onchange
//https://developer.apple.com/documentation/swiftui/view/onchange(of:perform:)
// showingNaiveSafariViewFullScreenCover = true
// dissmiss()
showingNaiveSafariViewFullScreenCover.toggle()
}
}
.onChange(of: cameraViewModel.isNeedPopErr){oldState,newState in
if(oldState == false && newState == true){
showTopToast.toggle()
}
}
.fullScreenCover(isPresented: $showingNaiveSafariViewFullScreenCover) {
XuXuWebView(currentUrl: cameraViewModel.recordDetailUrl())
}
.present(isPresented: self.$showTopToast, type: .toast, position: .top, duration: 6.0, onTap: {
}, onToastDismiss: {
dissmiss()
}) {
self.createTopToastView()
}
HStack {
Image(systemName: "arrow.backward")
.frame(width: 50, height: 50)
.foregroundColor(.secondary)
.strokeStyle(cornerRadius: 15)
.onTapGesture {
dissmiss()
}
Text("Scanning").font(.headline).frame(maxWidth: .infinity, alignment: .center)
}.padding(.top,50)
}
// .navigationBarTitleDisplayMode(.inline)
// .toolbar{
// ToolbarItem(placement:.principal){
//
//
// Text("Timer").font(.headline)
// }
// }
//
// .navigationBarItems(
// leading: Button(action: {
// dissmiss()
// }, label: {
// Image(systemName: "arrow.backward")
// .foregroundColor(Color(red: 231/255, green: 200/255, blue: 153/255))
// }), trailing: Button(action: {
// // 点击按钮时的操作
// }, label: {
// Text("明细")
// .foregroundColor(Color(red: 231/255, green: 200/255, blue: 153/255))
// }))
}
let topToastColor = Color(hex: "4067CC")
@State var showTopToast = false
func createTopToastView() -> some View {
VStack {
Spacer(minLength: 30)
HStack() {
Image("AppIcon")
.resizable()
.aspectRatio(contentMode: ContentMode.fill)
.frame(width: 50, height: 50)
.cornerRadius(25)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text("Error")
.font(.system(size: 18))
.foregroundColor(.white)
.fontWeight(.bold)
Spacer()
Image(systemName: "info")
.font(.system(size: 12))
.foregroundColor(.white)
}
Text("\(cameraViewModel.xuxuError?.message)" + "!")
.lineLimit(2)
.font(.system(size: 16))
.foregroundColor(.white)
}
}.padding(15)
}
.frame(height: 130)
.background(self.topToastColor)
}
}
#Preview {
CameraView()
// CameraView(image: .constant(nil),array3D: .constant([]))
}
@@ -0,0 +1,92 @@
//
// CameraViewModel.swift
// XuXuCheck
//
// Created by henseycai on 2025/6/25.
//
import Foundation
import CoreImage
@Observable
class CameraViewModel {
var currentFrame: CGImage?
var array3D: [[[Double]]] = []
var arrayPaperDesc: [Int] = [0,0,0,0]
var isSuccess: Bool = false
var isDataSent: Bool = false
var recordId: Int = 0
var recordResultModel: RecordResultModel? = nil
var xuxuError:XuXuRespError? = nil
var isNeedPopErr = false
private let cameraManager = CameraManager()
init() {
Task {
await handleCameraPreviews()
}
}
private func resetData(){
recordId = 0
isSuccess = false
isDataSent = false
isNeedPopErr = false
array3D = []
}
func startSession(){
//init data
resetData()
cameraManager.StartCapture()
}
func stopSession(){
cameraManager.StopCapture()
}
func handleCameraPreviews() async {
for await image in cameraManager.previewStream {
Task { @MainActor in
currentFrame = image
}
}
}
func recordDetailUrl() -> String{
let res = API.webrecordDetailUrl + GetGeneralRequestParams() + "&record_type=0&record_id=\(recordId)"
return res
}
func addRecord(desc: [Int]){
self.isDataSent = true
APIService.instance.addNewRecord(with: desc, with: array3D, completion: {(model,error) in
// debugPrint(desc[0])
// debugPrint(desc[1])
// debugPrint(desc[2])
if let error = error{
debugPrint(error)
self.xuxuError = error
self.isNeedPopErr = true
return
}
self.recordResultModel = model
self.recordId = model?.data?.record_id ?? 0
self.isSuccess = true
})
}
}
+41
View File
@@ -0,0 +1,41 @@
//
// ScanView.swift
// XuXuCheck
//
// Created by henseycai on 2025/6/25.
//
import SwiftUI
struct ScanView: View {
// @State
// private var viewModel = CameraViewModel()
// @Environment(CameraViewModel.self) private var cameraViewModel
@State private var canStartScan = false
@Environment(CameraViewModel.self) private var cameraViewModel
var body: some View {
// CameraView(image: $viewModel.currentFrame,array3D: $viewModel.array3D)
// .ignoresSafeArea()
if(canStartScan){
CameraView()
.onDisappear {
//can this line be called?????????????????
cameraViewModel.stopSession()
}
.ignoresSafeArea()
}else{
TimerCountView(canStartScan: $canStartScan)
.ignoresSafeArea()
}
}
}
#Preview {
ScanView()
}
@@ -0,0 +1,131 @@
//
// TimerCountView.swift
// XuXuCheck
//
// Created by henseycai on 2025/7/25.
//
import SwiftUI
struct TimerCountView: View {
@State var timeRemaining: Int = 60
@State var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@Binding var canStartScan: Bool
@Environment(\.dismiss) private var dissmiss
@Environment(CameraViewModel.self) private var cameraViewModel
var body: some View {
let bgcolor = Color(hex: 0x2449f1)
let bgcolor2 = Color(hex: 0x1638d9)
VStack{
HStack {
Image(systemName: "arrow.backward")
.frame(width: 50, height: 50)
.foregroundColor(.primary)
.strokeStyle(cornerRadius: 15)
.onTapGesture {
dissmiss()
}
Text("Countdown Timer").font(.headline).frame(maxWidth: .infinity, alignment: .center)
}
ZStack {
// Digit Counter
Text(
"\(timeRemaining)s"
)
.font(.system(size: 56, weight: .bold))
// Circle Counter
Group {
Circle()
.rotation(.degrees(-90))
.stroke(
bgcolor.opacity(0.3),
style: StrokeStyle(lineWidth: 12, dash: [CGFloat.pi / 2, CGFloat.pi * 3.5])
)
Circle()
.trim(
from: 0,
to: (CGFloat(timeRemaining).truncatingRemainder(dividingBy: 60) - 0.25) / 60
)
.rotation(.degrees(-90))
.stroke(
bgcolor2.opacity(0.8),
style: StrokeStyle(lineWidth: 12, dash: [CGFloat.pi / 2, CGFloat.pi * 3.5])
)
}
.frame(width: 240, height: 240)
}.onReceive(timer){ _ in
if timeRemaining > 0 {
// if timeremaining is greater than zero subtract 1
timeRemaining -= 1
}
else
{
//else reset it to 5 if its equal to 0. this will save it from a negative num
timeRemaining = 60
self.timer.upstream.connect().cancel()
cameraViewModel.startSession()
canStartScan = true
//dissmiss()
}
}
Text("Urine chemical reaction needs one minute about. Please put strip on white paper towel.").foregroundStyle(.primary)
.font(.title2).frame(maxWidth: .infinity, alignment: .center)
.offset(x:20,y:100)
///-----------------------------
Spacer()
HStack{
Button(action: {
timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
timeRemaining = 60
}, label: {
Text("Reset Timer")
.frame(width: 120)
.padding()
.foregroundColor(.white)
.background(Color.gray)
.cornerRadius(10)
})
Spacer()
Button(action: {
self.timer.upstream.connect().cancel()
cameraViewModel.startSession()
canStartScan = true
}, label: {
Text("Scan Now")
.frame(width: 120)
.padding()
.foregroundColor(.white)
.background(Color.blue)
.cornerRadius(10)
})
}
}.padding(50)
}
}