mirror of
https://github.com/luxfhe/example-ios-demo.git
synced 2026-10-30 18:13:24 +00:00
This app is ZAMA's BSD 3-Clause Clear demo. The license requires its notices to survive redistribution, and a rebrand had replaced every one of them with ours: the LICENSE text, 66 source headers and the app's copyright string. They are back, each with our line beside it for our changes. Co-authored-by: Hanzo Dev <[email protected]>
275 lines
11 KiB
Swift
275 lines
11 KiB
Swift
// Copyright © 2025 Zama. All rights reserved.
|
|
// Copyright © 2025 Lux Industries Inc.
|
|
|
|
import SwiftUI
|
|
|
|
#Preview {
|
|
SleepTab()
|
|
}
|
|
|
|
struct SleepTab: View {
|
|
@StateObject private var vm = ViewModel()
|
|
@State private var selectedDate: Date?
|
|
@State private var path: [Destination] = []
|
|
|
|
enum Destination {
|
|
case calendar
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack(path: $path) {
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
if vm.allSamples.isEmpty {
|
|
OpenAppButton(.luxDataVault(tab: .sleep))
|
|
} else if let sample = vm.selectedSample {
|
|
|
|
let isProcessing = if case .progress = vm.status { true } else { false }
|
|
|
|
Button(action: {
|
|
path.append(.calendar)
|
|
}) {
|
|
Text("\(sample.date.formatted(date: .numeric, time: .omitted))")
|
|
.frame(maxWidth: .infinity)
|
|
.overlay(alignment: .trailing) {
|
|
Image(systemName: "chevron.down")
|
|
.padding()
|
|
}
|
|
.overlay(alignment: .leading) {
|
|
if isProcessing {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}.disabled(isProcessing)
|
|
|
|
} else {
|
|
Button("Select Encrypted Data") {
|
|
path.append(.calendar)
|
|
}
|
|
}
|
|
|
|
CustomBox("Sleep Phase") {
|
|
if let url = vm.selectedSample?.url {
|
|
FilePreview(url: url)
|
|
.frame(height: 170)
|
|
|
|
Text("""
|
|
**Awake**: Often brief and unnoticed.
|
|
**REM**: Dreaming stage, crucial for memory and emotions.
|
|
**Core**: Light sleep, prepares the body for deeper stages.
|
|
**Deep**: Restorative sleep, vital for physical recovery and growth.
|
|
""")
|
|
.fontWeight(.regular)
|
|
.customFont(.caption2)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
} else {
|
|
NoDataBadge()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
CustomBox("Sleep Quality") {
|
|
if let url = vm.resultURL {
|
|
FilePreview(url: url)
|
|
.frame(height: 130)
|
|
} else {
|
|
if let status = vm.status {
|
|
AsyncStatus(status)
|
|
.frame(maxWidth: .infinity, minHeight: 120)
|
|
} else if vm.resultURL == nil {
|
|
NoDataBadge()
|
|
.frame(maxWidth: .infinity, minHeight: 120)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationDestination(for: Destination.self) { value in
|
|
switch value {
|
|
case .calendar:
|
|
VStack(spacing: 0) {
|
|
Text("\(Image(systemName: "bed.double.fill")) Historical Data")
|
|
.font(.largeTitle)
|
|
.bold()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding()
|
|
|
|
LuxCalendar(covering: vm.samplesInterval ?? Calendar.current.dateInterval(of: .month, for: .now)!,
|
|
selection: $selectedDate,
|
|
canSelect: { item in vm.allSamples.contains { $0.date == item } })
|
|
}
|
|
.background(Color.luxYellowLight)
|
|
.onChange(of: selectedDate) {
|
|
Task {
|
|
try await Task.sleep(for: .seconds(0.1))
|
|
path = []
|
|
if let selectedDate {
|
|
await vm.onDateSelected(date: selectedDate)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Sleep Analysis")
|
|
.padding()
|
|
.buttonStyle(.lux)
|
|
.background(Color.luxYellowLight)
|
|
.onAppearAgain {
|
|
vm.refreshFromDisk()
|
|
}
|
|
}
|
|
.tint(.black)
|
|
}
|
|
}
|
|
|
|
extension SleepTab {
|
|
@MainActor final class ViewModel: ObservableObject {
|
|
typealias Selection = (date: Date, url: URL, data: Data)
|
|
typealias Sample = (date: Date, url: URL)
|
|
private let input: Storage.File = .sleepList
|
|
private let serverTask: Network.ServerTask = .sleep_quality
|
|
|
|
@Published var allSamples: [Sample] = []
|
|
@Published var selectedSample: Selection?
|
|
@Published var samplesInterval: DateInterval?
|
|
@Published var resultURL: URL?
|
|
@Published var status: ActivityStatus?
|
|
|
|
func refreshFromDisk() {
|
|
Task {
|
|
do {
|
|
try readSamplesFromDisk()
|
|
if let saved = Constants.selectedNight {
|
|
await onDateSelected(date: saved)
|
|
}
|
|
} catch {
|
|
print(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func readSamplesFromDisk() throws {
|
|
let nightURLs = try Storage.listEncryptedFiles(matching: .sleepList)
|
|
let samples = nightURLs.compactMap { url in
|
|
if let date = Storage.date(from: url.lastPathComponent) {
|
|
return (date, url)
|
|
} else {
|
|
print("Error parsing date from URL: \(url)")
|
|
return nil
|
|
}
|
|
}
|
|
self.allSamples = samples
|
|
|
|
let dates = samples.map(\.0)
|
|
if let min = dates.min(), let max = dates.max() {
|
|
self.samplesInterval = DateInterval(start: min, end: max)
|
|
}
|
|
}
|
|
|
|
func onDateSelected(date: Date) async {
|
|
guard let nightFileURL = allSamples.first(where: { $0.date == date })?.url,
|
|
let data = await Storage.read(nightFileURL) else {
|
|
return
|
|
}
|
|
|
|
// Hack to force QL Preview to reload…
|
|
self.selectedSample = nil
|
|
Constants.selectedNightInputPreviewURL = nil
|
|
Constants.selectedNight = nil
|
|
self.resultURL = nil
|
|
try? await Task.sleep(for: .seconds(0.01))
|
|
|
|
|
|
self.selectedSample = (date, nightFileURL, data)
|
|
let suffix = Storage.suffix(for: date)
|
|
let inputPreviewURL = Storage.url(for: .sleepList, suffix: "\(suffix)-preview")
|
|
Constants.selectedNightInputPreviewURL = inputPreviewURL
|
|
Constants.selectedNight = date
|
|
Constants.selectedNightResultPreviewURL = nil
|
|
|
|
// Bypass server upload if result is already on disk
|
|
if let existing = await existingResult(for: date) {
|
|
self.resultURL = existing.file
|
|
Constants.selectedNightResultPreviewURL = existing.preview
|
|
return
|
|
}
|
|
|
|
do {
|
|
self.status = .progress("Uploading Server Key…")
|
|
let uid = try await uploadServerKey()
|
|
|
|
self.status = .progress("Uploading Encrypted Data…")
|
|
let taskID = try await uploadSample(data, uid: uid)
|
|
|
|
self.status = .progress("Analyzing sleep quality…")
|
|
self.resultURL = try await getServerResult(uid: uid, taskID: taskID, for: date)
|
|
self.status = nil
|
|
} catch {
|
|
self.status = .error(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
func existingResult(for date: Date) async -> (file: URL, preview: URL)? {
|
|
let suffix = Storage.suffix(for: date)
|
|
let resultURL = Storage.url(for: .sleepScore, suffix: suffix)
|
|
let previewURL = Storage.url(for: .sleepScore, suffix: "\(suffix)-preview")
|
|
let data = await Storage.read(resultURL)
|
|
return data == nil ? nil : (file: resultURL, preview: previewURL)
|
|
}
|
|
|
|
// MARK: - PRIVATE -
|
|
|
|
private func uploadServerKey() async throws -> Network.UID {
|
|
guard let keyToUpload = await Storage.read(.serverKey) else {
|
|
throw CustomError.missingServerKey
|
|
}
|
|
|
|
let hash = keyToUpload.stableHashValue
|
|
if hash == Constants.uploadedServerKeyHash, let uid = Constants.uploadedServerKeyUID {
|
|
return uid // Already uploaded
|
|
}
|
|
|
|
// TODO: prevent reentrancy, if already uploading
|
|
|
|
let newUID = try await Network.shared.uploadServerKey(keyToUpload, for: serverTask)
|
|
Constants.uploadedServerKeyHash = hash
|
|
Constants.uploadedServerKeyUID = newUID
|
|
return newUID
|
|
}
|
|
|
|
private func uploadSample(_ sampleToUpload: Data, uid: Network.UID) async throws -> Network.TaskID {
|
|
let hash = sampleToUpload.stableHashValue
|
|
if hash == self.uploadedNightHash, let taskID = self.uploadedNightTaskID {
|
|
return taskID // Already uploaded
|
|
}
|
|
|
|
// TODO: prevent reentrancy, if already uploading
|
|
|
|
let taskID = try await Network.shared.startTask(serverTask, uid: uid, encrypted_input: sampleToUpload)
|
|
self.uploadedNightHash = hash
|
|
self.uploadedNightTaskID = taskID
|
|
return taskID
|
|
}
|
|
|
|
private func getServerResult(uid: Network.UID, taskID: Network.TaskID, for date:Date) async throws -> URL {
|
|
let result = try await Network.shared.getSleepResult(taskID: taskID, uid: uid)
|
|
|
|
let suffix = Storage.suffix(for: date)
|
|
let resultURL = Storage.url(for: .sleepScore, suffix: suffix)
|
|
let resultPreviewURL = Storage.url(for: .sleepScore, suffix: "\(suffix)-preview")
|
|
|
|
try await Storage.write(resultURL, data: result)
|
|
try await Storage.write(resultPreviewURL, data: result)
|
|
Constants.selectedNightResultPreviewURL = resultPreviewURL
|
|
|
|
return Storage.url(for: .sleepScore, suffix: suffix)
|
|
}
|
|
|
|
@UserDefaultsStorage(key: "v12.uploadedNightHash", defaultValue: nil)
|
|
private var uploadedNightHash: String?
|
|
|
|
@UserDefaultsStorage(key: "v12.uploadedNightTaskID", defaultValue: nil)
|
|
private var uploadedNightTaskID: Network.TaskID?
|
|
}
|
|
}
|