diff --git a/Gym Routine Tracker Plus.xcodeproj/project.pbxproj b/Gym Routine Tracker Plus.xcodeproj/project.pbxproj index cd51617..a8b2398 100644 --- a/Gym Routine Tracker Plus.xcodeproj/project.pbxproj +++ b/Gym Routine Tracker Plus.xcodeproj/project.pbxproj @@ -7,10 +7,16 @@ objects = { /* Begin PBXBuildFile section */ + 4001ED0D29732D4500F165ED /* ExportSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4001ED0C29732D4500F165ED /* ExportSettings.swift */; }; + 4001ED0F29732E1300F165ED /* ColorSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4001ED0E29732E1300F165ED /* ColorSettings.swift */; }; + 401064D92973374700BFB00E /* ZipDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401064D82973374700BFB00E /* ZipDocument.swift */; }; 4011EECD2961C9B000A36D87 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 4011EECC2961C9B000A36D87 /* LICENSE */; }; 40245EF6296BE7D4007B5DAB /* ExerciseRunList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40245EF5296BE7D4007B5DAB /* ExerciseRunList.swift */; }; 402A5F2F296B7FE000A43DB3 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 402A5F2E296B7F7B00A43DB3 /* HistoryView.swift */; }; 402A5F32296B835B00A43DB3 /* Tabler in Frameworks */ = {isa = PBXBuildFile; productRef = 402A5F31296B835B00A43DB3 /* Tabler */; }; + 403CA2CE2971F6660084785B /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 403CA2CD2971F6660084785B /* ZIPFoundation */; }; + 403CA2D0297220460084785B /* Export-Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403CA2CF29721E750084785B /* Export-Utils.swift */; }; + 403CA2D22972829F0084785B /* PhoneSettingsForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403CA2D12972829F0084785B /* PhoneSettingsForm.swift */; }; 408FACFE296CAC9300D02C9C /* RoutineRunList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408FACFD296CABC400D02C9C /* RoutineRunList.swift */; }; 40B51E45294549DF0047377A /* GroutUI in Frameworks */ = {isa = PBXBuildFile; productRef = 40B51E44294549DF0047377A /* GroutUI */; }; 40CCF3F629454D2B007DDE69 /* GroutLib in Frameworks */ = {isa = PBXBuildFile; productRef = 40CCF3F529454D2B007DDE69 /* GroutLib */; }; @@ -23,10 +29,15 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 4001ED0C29732D4500F165ED /* ExportSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportSettings.swift; sourceTree = ""; }; + 4001ED0E29732E1300F165ED /* ColorSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorSettings.swift; sourceTree = ""; }; + 401064D82973374700BFB00E /* ZipDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZipDocument.swift; sourceTree = ""; }; 4011EECC2961C9B000A36D87 /* LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 4011EECE2961C9BC00A36D87 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 40245EF5296BE7D4007B5DAB /* ExerciseRunList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExerciseRunList.swift; sourceTree = ""; }; 402A5F2E296B7F7B00A43DB3 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + 403CA2CF29721E750084785B /* Export-Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Export-Utils.swift"; sourceTree = ""; }; + 403CA2D12972829F0084785B /* PhoneSettingsForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneSettingsForm.swift; sourceTree = ""; }; 408FACFD296CABC400D02C9C /* RoutineRunList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutineRunList.swift; sourceTree = ""; }; 40E347BA29452CF1003A19B9 /* Gym Routine Tracker Plus.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Gym Routine Tracker Plus.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 40E347BD29452CF1003A19B9 /* PlusApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlusApp.swift; sourceTree = ""; }; @@ -47,12 +58,36 @@ 40E347D329452E4B003A19B9 /* Compactor in Frameworks */, 40EBE1CC29635306004B9189 /* GroutUI in Frameworks */, 40CCF3F629454D2B007DDE69 /* GroutLib in Frameworks */, + 403CA2CE2971F6660084785B /* ZIPFoundation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 401064DA2973378500BFB00E /* Helper */ = { + isa = PBXGroup; + children = ( + 401064D82973374700BFB00E /* ZipDocument.swift */, + 403CA2CF29721E750084785B /* Export-Utils.swift */, + ); + path = Helper; + sourceTree = ""; + }; + 401064DB2973378C00BFB00E /* Views */ = { + isa = PBXGroup; + children = ( + 40E347BF29452CF1003A19B9 /* ContentView.swift */, + 4001ED0E29732E1300F165ED /* ColorSettings.swift */, + 40245EF5296BE7D4007B5DAB /* ExerciseRunList.swift */, + 4001ED0C29732D4500F165ED /* ExportSettings.swift */, + 402A5F2E296B7F7B00A43DB3 /* HistoryView.swift */, + 403CA2D12972829F0084785B /* PhoneSettingsForm.swift */, + 408FACFD296CABC400D02C9C /* RoutineRunList.swift */, + ); + path = Views; + sourceTree = ""; + }; 40B51E41294549D90047377A /* Frameworks */ = { isa = PBXGroup; children = ( @@ -83,13 +118,11 @@ isa = PBXGroup; children = ( 40E347BD29452CF1003A19B9 /* PlusApp.swift */, - 40E347BF29452CF1003A19B9 /* ContentView.swift */, - 402A5F2E296B7F7B00A43DB3 /* HistoryView.swift */, - 408FACFD296CABC400D02C9C /* RoutineRunList.swift */, - 40245EF5296BE7D4007B5DAB /* ExerciseRunList.swift */, + 401064DB2973378C00BFB00E /* Views */, + 401064DA2973378500BFB00E /* Helper */, + 40E347C329452CF1003A19B9 /* App.entitlements */, 40E347C129452CF1003A19B9 /* Assets.xcassets */, 40EBCFDE294BFB880082A172 /* Info.plist */, - 40E347C329452CF1003A19B9 /* App.entitlements */, 40E347C429452CF1003A19B9 /* Preview Content */, ); path = Sources; @@ -125,6 +158,7 @@ 40CCF3F529454D2B007DDE69 /* GroutLib */, 40EBE1CB29635306004B9189 /* GroutUI */, 402A5F31296B835B00A43DB3 /* Tabler */, + 403CA2CD2971F6660084785B /* ZIPFoundation */, ); productName = "Gym Routine Tracker Plus"; productReference = 40E347BA29452CF1003A19B9 /* Gym Routine Tracker Plus.app */; @@ -158,6 +192,7 @@ 40E347D129452E4B003A19B9 /* XCRemoteSwiftPackageReference "SwiftCompactor" */, 40EBE1CA29635306004B9189 /* XCRemoteSwiftPackageReference "GroutUI" */, 402A5F30296B835B00A43DB3 /* XCRemoteSwiftPackageReference "SwiftTabler" */, + 403CA2CC2971F6660084785B /* XCRemoteSwiftPackageReference "ZIPFoundation" */, ); productRefGroup = 40E347BB29452CF1003A19B9 /* Products */; projectDirPath = ""; @@ -187,6 +222,11 @@ buildActionMask = 2147483647; files = ( 40E347C029452CF1003A19B9 /* ContentView.swift in Sources */, + 403CA2D22972829F0084785B /* PhoneSettingsForm.swift in Sources */, + 4001ED0F29732E1300F165ED /* ColorSettings.swift in Sources */, + 4001ED0D29732D4500F165ED /* ExportSettings.swift in Sources */, + 401064D92973374700BFB00E /* ZipDocument.swift in Sources */, + 403CA2D0297220460084785B /* Export-Utils.swift in Sources */, 408FACFE296CAC9300D02C9C /* RoutineRunList.swift in Sources */, 40245EF6296BE7D4007B5DAB /* ExerciseRunList.swift in Sources */, 402A5F2F296B7FE000A43DB3 /* HistoryView.swift in Sources */, @@ -424,6 +464,14 @@ minimumVersion = 0.9.6; }; }; + 403CA2CC2971F6660084785B /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/weichsel/ZIPFoundation.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.9.9; + }; + }; 40E347D129452E4B003A19B9 /* XCRemoteSwiftPackageReference "SwiftCompactor" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/openalloc/SwiftCompactor.git"; @@ -448,6 +496,11 @@ package = 402A5F30296B835B00A43DB3 /* XCRemoteSwiftPackageReference "SwiftTabler" */; productName = Tabler; }; + 403CA2CD2971F6660084785B /* ZIPFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = 403CA2CC2971F6660084785B /* XCRemoteSwiftPackageReference "ZIPFoundation" */; + productName = ZIPFoundation; + }; 40B51E44294549DF0047377A /* GroutUI */ = { isa = XCSwiftPackageProductDependency; productName = GroutUI; diff --git a/Sources/Helper/Export-Utils.swift b/Sources/Helper/Export-Utils.swift new file mode 100644 index 0000000..c0f4b01 --- /dev/null +++ b/Sources/Helper/Export-Utils.swift @@ -0,0 +1,54 @@ +// +// Export-Utils.swift +// +// Copyright 2023 OpenAlloc LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// + +import CoreData +import ZIPFoundation + +import GroutLib + +public func createZipArchive(_ context: NSManagedObjectContext, + mainStore: NSPersistentStore, + archiveStore: NSPersistentStore, + format: ExportFormat = .CSV) throws -> Data? +{ + guard let archive = Archive(accessMode: .create) + else { throw DataError.archiveCreationFailure } + + func makeDelimFile(_: T.Type, + inStore: NSPersistentStore?) throws + { + let request = makeRequest(T.self, inStore: inStore) + let results = try context.fetch(request) + let data = try exportData(results, format: format) + let fileName = "\(T.fileNamePrefix).\(format.defaultFileExtension)" + try archive.addEntry(with: fileName, + type: .file, + uncompressedSize: Int64(data.count), + provider: { position, size -> Data in + let range = Int(position) ..< Int(position) + size + return data.subdata(in: range) + }) + } + + try makeDelimFile(Routine.self, inStore: mainStore) + try makeDelimFile(Exercise.self, inStore: mainStore) + + // NOT WORKING: Local function 'makeDelimFile(_:inStore:)' requires that 'NSManagedObject' conform to 'Encodable' + // [ZRoutine.self, ZRoutineRun.self, ZExercise.self, ZExerciseRun.self].forEach { + // try makeDelimFile($0, inStore: archiveStore) + // } + + try makeDelimFile(ZRoutine.self, inStore: archiveStore) + try makeDelimFile(ZRoutineRun.self, inStore: archiveStore) + try makeDelimFile(ZExercise.self, inStore: archiveStore) + try makeDelimFile(ZExerciseRun.self, inStore: archiveStore) + + return archive.data +} diff --git a/Sources/Helper/ZipDocument.swift b/Sources/Helper/ZipDocument.swift new file mode 100644 index 0000000..6f0a35b --- /dev/null +++ b/Sources/Helper/ZipDocument.swift @@ -0,0 +1,32 @@ +// +// ZipDocument.swift +// +// Copyright 2022, 2023 OpenAlloc LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// + +import SwiftUI +import UniformTypeIdentifiers + +/// Simple wrapper for Zip Archive data +public struct ZipDocument: FileDocument { + public static var readableContentTypes = [UTType.zip] + public static var writableContentTypes = [UTType.zip] + + private var data: Data + + public init(data: Data) { + self.data = data + } + + public init(configuration: ReadConfiguration) throws { + data = configuration.file.regularFileContents ?? Data() + } + + public func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} diff --git a/Sources/Views/ColorSettings.swift b/Sources/Views/ColorSettings.swift new file mode 100644 index 0000000..f9cddff --- /dev/null +++ b/Sources/Views/ColorSettings.swift @@ -0,0 +1,44 @@ +// +// ColorSettings.swift +// +// Copyright 2023 OpenAlloc LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// + +import SwiftUI + +import GroutLib +import GroutUI + +struct ColorSettings: View { + // MARK: - Locals + + @AppStorage(colorSchemeModeKey) var colorSchemeMode: ColorSchemeMode = .automatic + + // MARK: - Views + + var body: some View { + Section { + Picker("Color", selection: $colorSchemeMode) { + ForEach(ColorSchemeMode.allCases, id: \.self) { mode in + Text(mode.description).tag(mode) + } + } + .pickerStyle(SegmentedPickerStyle()) + } header: { + Text("Color Scheme") + .foregroundStyle(.tint) + } + } +} + +struct ColorSettings_Previews: PreviewProvider { + static var previews: some View { + Form { + ColorSettings() + } + } +} diff --git a/Sources/ContentView.swift b/Sources/Views/ContentView.swift similarity index 93% rename from Sources/ContentView.swift rename to Sources/Views/ContentView.swift index 945af76..e697ef7 100644 --- a/Sources/ContentView.swift +++ b/Sources/Views/ContentView.swift @@ -8,7 +8,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. // -import CoreData +import os import SwiftUI import GroutLib @@ -28,6 +28,9 @@ struct ContentView: View { @SceneStorage("main-history-nav") private var historyNavData: Data? @SceneStorage("main-settings-nav") private var settingsNavData: Data? + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: ContentView.self)) + var body: some View { TabView(selection: $selectedTab) { NavStack(name: "routines", @@ -51,7 +54,7 @@ struct ContentView: View { NavStack(name: "settings", navData: $settingsNavData) { - SettingsForm() + PhoneSettingsForm() } .tabItem { Label("Settings", systemImage: "gear") diff --git a/Sources/ExerciseRunList.swift b/Sources/Views/ExerciseRunList.swift similarity index 100% rename from Sources/ExerciseRunList.swift rename to Sources/Views/ExerciseRunList.swift diff --git a/Sources/Views/ExportSettings.swift b/Sources/Views/ExportSettings.swift new file mode 100644 index 0000000..5e4d75d --- /dev/null +++ b/Sources/Views/ExportSettings.swift @@ -0,0 +1,109 @@ +// +// ExportSettings.swift +// +// Copyright 2023 OpenAlloc LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// + +import CoreData +import os +import SwiftUI + +import GroutLib +import GroutUI + +struct ExportSettings: View { + @Environment(\.managedObjectContext) private var viewContext + + // MARK: - Locals + + @AppStorage(exportFormatKey) var exportFormat: ExportFormat = .CSV + + @State private var showFileExport = false + @State private var zipDocument: ZipDocument? + @State private var zipFileName: String? + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: ExportSettings.self)) + + // MARK: - Views + + var body: some View { + Section { + Button(action: exportAction) { + Text("Export Data") + } + Picker("", selection: $exportFormat) { + ForEach(ExportFormat.allCases, id: \.self) { mode in + Text(mode.defaultFileExtension.uppercased()) + .tag(mode) + } + } + .pickerStyle(SegmentedPickerStyle()) + } header: { + Text("Data Export") + .foregroundStyle(.tint) + } footer: { + Text("Will export data to ZIP archive containing \(exportFormat.description) (\(exportFormat.rawValue)) files.") + } + .fileExporter(isPresented: $showFileExport, + document: zipDocument, + contentType: .zip, + defaultFilename: zipFileName) { result in + switch result { + case let .success(url): + logger.notice("\(#function): saved to \(url)") + case let .failure(error): + logger.error("\(#function): \(error.localizedDescription)") + } + zipDocument = nil + zipFileName = nil + } + } + + // MARK: - Actions + + private func exportAction() { + if let document = createZipDocument() { + zipDocument = document + zipFileName = generateTimestampFileName(prefix: "grt-", suffix: ".zip") + showFileExport = true + } else { + logger.error("Unable to generate zip document, so not exporting.") + } + } + + // MARK: - Helpers + + private func createZipDocument() -> ZipDocument? { + logger.notice("\(#function) ENTER") + do { + if let mainStore = PersistenceManager.getMainStore(viewContext), + let archiveStore = PersistenceManager.getArchiveStore(viewContext), + let data = try createZipArchive(viewContext, + mainStore: mainStore, + archiveStore: archiveStore, + format: exportFormat) + { + logger.notice("\(#function) EXIT (success)") + return ZipDocument(data: data) + } + } catch { + logger.error("\(#function): ERROR \(error.localizedDescription)") + } + + logger.notice("\(#function) EXIT (failure)") + return nil + } +} + +struct ExportSettings_Previews: PreviewProvider { + static var previews: some View { + Form { + ExportSettings() + } + } +} diff --git a/Sources/HistoryView.swift b/Sources/Views/HistoryView.swift similarity index 100% rename from Sources/HistoryView.swift rename to Sources/Views/HistoryView.swift diff --git a/Sources/Views/PhoneSettingsForm.swift b/Sources/Views/PhoneSettingsForm.swift new file mode 100644 index 0000000..6846b34 --- /dev/null +++ b/Sources/Views/PhoneSettingsForm.swift @@ -0,0 +1,47 @@ +// +// PhoneSettingsForm.swift +// +// Copyright 2023 OpenAlloc LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// + +import os +import SwiftUI + +import GroutLib +import GroutUI + +struct PhoneSettingsForm: View { + @EnvironmentObject private var router: MyRouter + + // MARK: - Views + + var body: some View { + SettingsForm { + ColorSettings() + + ExportSettings() + + Button(action: { + router.path.append(MyRoutes.about) + }) { + Text("About \(appName)") + } + } + } + + // MARK: - Properties + + private var appName: String { + Bundle.main.appName ?? "unknown" + } +} + +struct PhoneSettingsForm_Previews: PreviewProvider { + static var previews: some View { + PhoneSettingsForm() + } +} diff --git a/Sources/RoutineRunList.swift b/Sources/Views/RoutineRunList.swift similarity index 100% rename from Sources/RoutineRunList.swift rename to Sources/Views/RoutineRunList.swift