From 040daeff3ec2f4069be2497b56d6f24b397be34a Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Tue, 26 Sep 2023 15:31:40 -0700 Subject: [PATCH] Add Export Button (#16) --- OwnYourData.xcodeproj/project.pbxproj | 8 ++ .../Documents/DocumentGallery.swift | 1 + TemplateApplication/FHIR Standard/FHIR.swift | 4 + TemplateApplication/Home.swift | 1 + .../Instructions/Instructions.swift | 1 + .../AccountSetup/AccountSetup.swift | 2 + .../Onboarding/HealthKitPermissions.swift | 1 + TemplateApplication/Onboarding/Welcome.swift | 6 +- .../Overview/InstructionsView.swift | 1 + .../Overview/LLMSummaryView.swift | 1 + .../Profile/ExportPackage.swift | 61 ++++++++++ TemplateApplication/Profile/ProfileView.swift | 103 +++++++++++------ TemplateApplication/Profile/URL+Zip.swift | 108 ++++++++++++++++++ 13 files changed, 261 insertions(+), 37 deletions(-) create mode 100644 TemplateApplication/Profile/ExportPackage.swift create mode 100644 TemplateApplication/Profile/URL+Zip.swift diff --git a/OwnYourData.xcodeproj/project.pbxproj b/OwnYourData.xcodeproj/project.pbxproj index 3d0eac6..dacbc6c 100644 --- a/OwnYourData.xcodeproj/project.pbxproj +++ b/OwnYourData.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 2F0191F92A9E4CF100E9EB0E /* String+LocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0191F82A9E4CF100E9EB0E /* String+LocalizedError.swift */; }; 2F0191FB2A9E579E00E9EB0E /* FHIRMultipleResourceInterpreter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0191FA2A9E579E00E9EB0E /* FHIRMultipleResourceInterpreter.swift */; }; 2F0191FD2A9E57BD00E9EB0E /* Prompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0191FC2A9E57BD00E9EB0E /* Prompt.swift */; }; + 2F0608CF2AC3884100836556 /* ExportPackage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0608CE2AC3884100836556 /* ExportPackage.swift */; }; 2F2146F42A82AF7F007CB929 /* SpeziOpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = 2F2146F32A82AF7F007CB929 /* SpeziOpenAI */; }; 2F2146F72A82AF9B007CB929 /* SpeziOnboarding in Frameworks */ = {isa = PBXBuildFile; productRef = 2F2146F62A82AF9B007CB929 /* SpeziOnboarding */; }; 2F2146FE2A82B236007CB929 /* SpeziFirebaseAccount in Frameworks */ = {isa = PBXBuildFile; productRef = 2F2146FD2A82B236007CB929 /* SpeziFirebaseAccount */; }; @@ -36,6 +37,7 @@ 2F8537712A9DB6C8006994BB /* ModelsR4 in Frameworks */ = {isa = PBXBuildFile; productRef = 2F8537702A9DB6C8006994BB /* ModelsR4 */; }; 2F8537742A9DB6E5006994BB /* HealthKitOnFHIR in Frameworks */ = {isa = PBXBuildFile; productRef = 2F8537732A9DB6E5006994BB /* HealthKitOnFHIR */; }; 2F8537762A9DB781006994BB /* SpeziAccount in Frameworks */ = {isa = PBXBuildFile; productRef = 2F8537752A9DB781006994BB /* SpeziAccount */; }; + 2F9652FE2AC388F300977083 /* URL+Zip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F9652FD2AC388F300977083 /* URL+Zip.swift */; }; 2FA2023329CBCC0C0039C21A /* DocumentScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA2023229CBCC0C0039C21A /* DocumentScanner.swift */; }; 2FB2943929CBA29900EE91A0 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB2943329CBA29900EE91A0 /* ProfileView.swift */; }; 2FB2943D29CBA29900EE91A0 /* ClinicalTrialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB2943729CBA29900EE91A0 /* ClinicalTrialsView.swift */; }; @@ -103,6 +105,7 @@ 2F0191F82A9E4CF100E9EB0E /* String+LocalizedError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+LocalizedError.swift"; sourceTree = ""; }; 2F0191FA2A9E579E00E9EB0E /* FHIRMultipleResourceInterpreter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FHIRMultipleResourceInterpreter.swift; sourceTree = ""; }; 2F0191FC2A9E57BD00E9EB0E /* Prompt.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Prompt.swift; sourceTree = ""; }; + 2F0608CE2AC3884100836556 /* ExportPackage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportPackage.swift; sourceTree = ""; }; 2F4E237D2989A2FE0013F3D9 /* OnboardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTests.swift; sourceTree = ""; }; 2F4E23822989D51F0013F3D9 /* TemplateAppTestingSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateAppTestingSetup.swift; sourceTree = ""; }; 2F5E32BC297E05EA003432F8 /* TemplateAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateAppDelegate.swift; sourceTree = ""; }; @@ -112,6 +115,7 @@ 2F8537672A9DB67E006994BB /* FHIRResource+Search.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "FHIRResource+Search.swift"; sourceTree = ""; }; 2F8537682A9DB67E006994BB /* FHIR.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FHIR.swift; sourceTree = ""; }; 2F8537692A9DB67E006994BB /* FHIRResource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FHIRResource.swift; sourceTree = ""; }; + 2F9652FD2AC388F300977083 /* URL+Zip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Zip.swift"; sourceTree = ""; }; 2FA2023229CBCC0C0039C21A /* DocumentScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentScanner.swift; sourceTree = ""; }; 2FAEC07F297F583900C11C42 /* TemplateApplication.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TemplateApplication.entitlements; sourceTree = ""; }; 2FB2943329CBA29900EE91A0 /* ProfileView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; @@ -294,6 +298,8 @@ isa = PBXGroup; children = ( 2FB2943329CBA29900EE91A0 /* ProfileView.swift */, + 2F0608CE2AC3884100836556 /* ExportPackage.swift */, + 2F9652FD2AC388F300977083 /* URL+Zip.swift */, ); path = Profile; sourceTree = ""; @@ -601,12 +607,14 @@ 2FA2023329CBCC0C0039C21A /* DocumentScanner.swift in Sources */, FCD3FFB729CD0C31004D1E0E /* LogoView.swift in Sources */, 2FB2FCD029CBDDC00027D85A /* TemplateSignUp.swift in Sources */, + 2F9652FE2AC388F300977083 /* URL+Zip.swift in Sources */, 2FB2FCD729CBDDC00027D85A /* Consent.swift in Sources */, 2FB2FCD329CBDDC00027D85A /* TemplateLogin.swift in Sources */, 2F8537652A9D1279006994BB /* HealthKitPermissions.swift in Sources */, 2FE573A729CD4672008EBBD4 /* PDFView.swift in Sources */, 2F0191F62A9E4CBD00E9EB0E /* OpenAIChatView.swift in Sources */, 2FE573A329CD4617008EBBD4 /* PDFDocument+Transferable.swift in Sources */, + 2F0608CF2AC3884100836556 /* ExportPackage.swift in Sources */, 2F4E23832989D51F0013F3D9 /* TemplateAppTestingSetup.swift in Sources */, 2F5E32BD297E05EA003432F8 /* TemplateAppDelegate.swift in Sources */, 2FB2FCD129CBDDC00027D85A /* UserView.swift in Sources */, diff --git a/TemplateApplication/Documents/DocumentGallery.swift b/TemplateApplication/Documents/DocumentGallery.swift index 2c8a03a..e7417b6 100644 --- a/TemplateApplication/Documents/DocumentGallery.swift +++ b/TemplateApplication/Documents/DocumentGallery.swift @@ -33,6 +33,7 @@ struct DocumentGallery: View { }, label: { Image(systemName: "plus") + .accessibilityLabel("Add Document") } ) } diff --git a/TemplateApplication/FHIR Standard/FHIR.swift b/TemplateApplication/FHIR Standard/FHIR.swift index 482dfc7..a737d53 100644 --- a/TemplateApplication/FHIR Standard/FHIR.swift +++ b/TemplateApplication/FHIR Standard/FHIR.swift @@ -35,6 +35,10 @@ actor FHIR: Standard, ObservableObject, ObservableObjectProvider, HealthKitConst Array(_resources.values) } + @MainActor var exportPackage: ExportPackage { + ExportPackage(resources: resources) + } + init() { guard HKHealthStore.isHealthDataAvailable() else { diff --git a/TemplateApplication/Home.swift b/TemplateApplication/Home.swift index 5359dad..9492c17 100644 --- a/TemplateApplication/Home.swift +++ b/TemplateApplication/Home.swift @@ -65,6 +65,7 @@ struct Home: View { }, label: { Image(systemName: "person.crop.circle") + .accessibilityLabel("Profile View") } ) } diff --git a/TemplateApplication/Instructions/Instructions.swift b/TemplateApplication/Instructions/Instructions.swift index 3c27bc3..be6324f 100644 --- a/TemplateApplication/Instructions/Instructions.swift +++ b/TemplateApplication/Instructions/Instructions.swift @@ -17,6 +17,7 @@ struct Instructions: View { ForEach(steps, id: \.offset) { step in HStack { Image(systemName: "\(step.offset + 1).circle.fill") + .accessibilityHidden(true) .foregroundColor(Color("ButtonColor_light")) .font(.system(size: 45)) .frame(minHeight: 90) diff --git a/TemplateApplication/Onboarding/AccountSetup/AccountSetup.swift b/TemplateApplication/Onboarding/AccountSetup/AccountSetup.swift index e439f54..afc06f5 100644 --- a/TemplateApplication/Onboarding/AccountSetup/AccountSetup.swift +++ b/TemplateApplication/Onboarding/AccountSetup/AccountSetup.swift @@ -77,8 +77,10 @@ struct AccountSetup: View { Group { if account.signedIn { Image(systemName: "person.badge.shield.checkmark.fill") + .accessibilityLabel("You are signed in") } else { Image(systemName: "person.fill.badge.plus") + .accessibilityLabel("You can sign up or sign in") } } .font(.system(size: 150)) diff --git a/TemplateApplication/Onboarding/HealthKitPermissions.swift b/TemplateApplication/Onboarding/HealthKitPermissions.swift index 92db3d1..c2d90e4 100644 --- a/TemplateApplication/Onboarding/HealthKitPermissions.swift +++ b/TemplateApplication/Onboarding/HealthKitPermissions.swift @@ -28,6 +28,7 @@ struct HealthKitPermissions: View { ) Spacer() Image(systemName: "heart.text.square.fill") + .accessibilityHidden(true) .font(.system(size: 150)) .foregroundColor(.accentColor) Text("HEALTHKIT_PERMISSIONS_DESCRIPTION") diff --git a/TemplateApplication/Onboarding/Welcome.swift b/TemplateApplication/Onboarding/Welcome.swift index 61ac211..b67d611 100644 --- a/TemplateApplication/Onboarding/Welcome.swift +++ b/TemplateApplication/Onboarding/Welcome.swift @@ -20,17 +20,17 @@ struct Welcome: View { subtitle: "WELCOME_SUBTITLE", areas: [ .init( - icon: Image(systemName: "folder.badge.plus"), + icon: Image(systemName: "folder.badge.plus"), // swiftlint:disable:this accessibility_label_for_image title: "WELCOME_AREA1_TITLE", description: "WELCOME_AREA1_DESCRIPTION" ), .init( - icon: Image(systemName: "magnifyingglass"), + icon: Image(systemName: "magnifyingglass"), // swiftlint:disable:this accessibility_label_for_image title: "WELCOME_AREA2_TITLE", description: "WELCOME_AREA2_DESCRIPTION" ), .init( - icon: Image(systemName: "square.and.arrow.up"), + icon: Image(systemName: "square.and.arrow.up"), // swiftlint:disable:this accessibility_label_for_image title: "WELCOME_AREA3_TITLE", description: "WELCOME_AREA3_DESCRIPTION" ) diff --git a/TemplateApplication/Overview/InstructionsView.swift b/TemplateApplication/Overview/InstructionsView.swift index f8c15ab..5c4d314 100644 --- a/TemplateApplication/Overview/InstructionsView.swift +++ b/TemplateApplication/Overview/InstructionsView.swift @@ -13,6 +13,7 @@ struct InstructionsView: View { var body: some View { VStack(alignment: .center) { Image(systemName: "doc.text.magnifyingglass") + .accessibilityHidden(true) .font(.system(size: 90)) .foregroundColor(.accentColor) .padding(.vertical, 8) diff --git a/TemplateApplication/Overview/LLMSummaryView.swift b/TemplateApplication/Overview/LLMSummaryView.swift index b2c1961..12ace58 100644 --- a/TemplateApplication/Overview/LLMSummaryView.swift +++ b/TemplateApplication/Overview/LLMSummaryView.swift @@ -23,6 +23,7 @@ struct LLMSummaryView: View { var body: some View { VStack(alignment: .center) { Image(systemName: "magnifyingglass") + .accessibilityHidden(true) .font(.system(size: 90)) .foregroundColor(.accentColor) .padding(.vertical, 8) diff --git a/TemplateApplication/Profile/ExportPackage.swift b/TemplateApplication/Profile/ExportPackage.swift new file mode 100644 index 0000000..bcf29b8 --- /dev/null +++ b/TemplateApplication/Profile/ExportPackage.swift @@ -0,0 +1,61 @@ +// +// This source file is part of the Stanford OwnYourData Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import CoreTransferable +import OSLog + + +struct ExportPackage: Transferable { + static var transferRepresentation: some TransferRepresentation { + FileRepresentation( + exportedContentType: .zip, + exporting: { document in + try await SentTransferredFile(document.zipRepresentation) + } + ) + } + + + let resources: [FHIRResource] + + private var directory: URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + "edu.stanford.ownyourdate.export", + isDirectory: true + ) + } + + var zipRepresentation: URL { + get async throws { + if directory.exists { + try FileManager.default.removeItem(at: directory) + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + + for resource in resources { + guard let id = resource.id else { + os_log("Can not export resource named \(resource.displayName)") + continue + } + + let resourceJSONData = Data(resource.jsonDescription.utf8) + try resourceJSONData.write(to: directory.appending(path: "\(id).json")) + } + + let zipURL = try directory.zip() + try FileManager.default.removeItem(at: directory) + + return zipURL + } + } + + + func deleteZipRepresentation() throws { + try FileManager.default.removeItem(at: directory.appendingPathExtension(".zip")) + } +} diff --git a/TemplateApplication/Profile/ProfileView.swift b/TemplateApplication/Profile/ProfileView.swift index 552624b..2760338 100644 --- a/TemplateApplication/Profile/ProfileView.swift +++ b/TemplateApplication/Profile/ProfileView.swift @@ -19,51 +19,86 @@ struct ProfileView: View { @AppStorage(StorageKeys.onboardingFlowComplete) var completedOnboardingFlow = false @EnvironmentObject var documentManager: DocumentManager + @EnvironmentObject var fhirStandard: FHIR var body: some View { - VStack { - Image(systemName: "person.circle.fill") - .resizable() - .scaledToFit() - .accessibility(label: Text("profile image")) - .foregroundColor(Color("ButtonColor_dark")) - .frame(width: 120, height: 120) - .padding(.top, 80) - VStack(spacing: 10) { - Text("\(firstName) \(lastName)") - .font(.title2) - Text("Email: \(email)") - .font(.subheadline) - } - Spacer() - OwnYourDataButton( - title: "Log Out", - action: { - do { - try Auth.auth().signOut() - - firstName = "" - lastName = "" - email = "" - - completedOnboardingFlow = false - - documentManager.removeAllDocuments() - - print("Logged out.") - } catch { - print("Error signing out: \(error)") + GeometryReader { proxy in + ScrollView(.vertical) { + VStack { + Image(systemName: "person.circle.fill") + .resizable() + .scaledToFit() + .accessibility(label: Text("profile image")) + .foregroundColor(Color("ButtonColor_dark")) + .frame(width: 120, height: 120) + .padding(.top, 40) + VStack(spacing: 10) { + Text("\(firstName) \(lastName)") + .font(.title2) + Text("Email: \(email)") + .font(.subheadline) } + sharebutton + Spacer(minLength: 64) + logoutButton } - ) + .padding(.bottom, 30) + .frame(minHeight: proxy.size.height) + } + .frame(width: proxy.size.width) } - .padding(.bottom, 30) .task { fetchUserData() } } + @ViewBuilder private var sharebutton: some View { + VStack(alignment: .center, spacing: 8) { + ShareLink( + item: fhirStandard.exportPackage, + preview: SharePreview( + Text("FHIR JSON Export Package") + ) + ) { + HStack { + Image(systemName: "square.and.arrow.up") + .accessibilityHidden(true) + Text("Export") + } + } + .buttonStyle(.borderedProminent) + Text("Export your health records to share them with the OwnYourData team.") + .multilineTextAlignment(.center) + .foregroundStyle(Color.accentColor) + .font(.caption) + } + .padding() + } + + @ViewBuilder private var logoutButton: some View { + OwnYourDataButton( + title: "Log Out", + action: { + do { + try Auth.auth().signOut() + + firstName = "" + lastName = "" + email = "" + + completedOnboardingFlow = false + + documentManager.removeAllDocuments() + + print("Logged out.") + } catch { + print("Error signing out: \(error)") + } + } + ) + } + private func fetchUserData() { if let currentUser = Auth.auth().currentUser { diff --git a/TemplateApplication/Profile/URL+Zip.swift b/TemplateApplication/Profile/URL+Zip.swift new file mode 100644 index 0000000..a590f31 --- /dev/null +++ b/TemplateApplication/Profile/URL+Zip.swift @@ -0,0 +1,108 @@ +// +// This source file is part of the Stanford OwnYourData Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import OSLog + + +extension URL { + var isDirectory: Bool { + (try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true + } + + var exists: Bool { + FileManager.default.fileExists(atPath: path) + } + + + func zip(to zipURL: URL? = nil) throws -> URL { + let logger = Logger(subsystem: "edu.stanford.spezi.zipcomponent", category: "ZipComponent") + + let zipURL = zipURL ?? self.appendingPathExtension("zip") + + let directory: URL + let temporaryDirectory: Bool + if isFileURL, exists, isDirectory { + directory = self + temporaryDirectory = false + } else { + // Crete a temporary folder + directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "edu.stanford.spezi.zipcomponent/\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + temporaryDirectory = true + + // Copy the file to the new folder + let filePath = directory.appendingPathComponent(lastPathComponent) + try FileManager.default.copyItem(at: self, to: filePath) + } + + // Clean up the folder at the end of the function independent of the outcome + defer { + if temporaryDirectory { + do { + try FileManager.default.removeItem(at: directory) + } catch { + logger.error("Could not remove temporary directory at \(directory)") + } + } + } + + var zipError, copyError: NSError? + + NSFileCoordinator().coordinate( + readingItemAt: directory, + options: .forUploading, + error: &zipError + ) { zippedURL in + do { + if zipURL.exists { + try FileManager.default.removeItem(at: zipURL) + } + try FileManager.default.copyItem(at: zippedURL, to: zipURL) + } catch { + logger.debug("Copying the ziped file from \(zippedURL) to \(zipURL) failed.") + copyError = error as NSError + } + } + + if let copyError { + throw copyError + } + if let zipError { + logger.debug("Could not zip the directory at \(directory): \(zipError)") + throw zipError + } + + return zipURL + } +} + + +extension Data { + var zip: Data { + get throws { + let temporaryDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( + "edu.stanford.spezi.zipcomponent/\(UUID().uuidString)", + isDirectory: true + ) + + try self.write(to: temporaryDirectory, options: .atomic) + let zipURL = try temporaryDirectory.zip() + + let zippedData = try Data(contentsOf: zipURL) + + try? FileManager.default.removeItem(at: temporaryDirectory) + try? FileManager.default.removeItem(at: zipURL) + + return zippedData + } + } +}