From f6591dfc50947da5a32090d642ef565a14cfdfba Mon Sep 17 00:00:00 2001 From: Reed Es Date: Wed, 11 Jan 2023 12:26:58 -0700 Subject: [PATCH] History Log Feature (#2) Lots of changes for this feature. --- .../project.pbxproj | 37 ++- README.md | 43 ++- Sources/App.entitlements | 3 + Sources/ContentView.swift | 41 ++- Sources/ExerciseRunList.swift | 244 ++++++++++++++++++ Sources/HistoryView.swift | 117 +++++++++ Sources/PlusApp.swift | 11 +- Sources/RoutineRunList.swift | 175 +++++++++++++ 8 files changed, 648 insertions(+), 23 deletions(-) create mode 100644 Sources/ExerciseRunList.swift create mode 100644 Sources/HistoryView.swift create mode 100644 Sources/RoutineRunList.swift diff --git a/Gym Routine Tracker Plus.xcodeproj/project.pbxproj b/Gym Routine Tracker Plus.xcodeproj/project.pbxproj index 104a2f7..cd51617 100644 --- a/Gym Routine Tracker Plus.xcodeproj/project.pbxproj +++ b/Gym Routine Tracker Plus.xcodeproj/project.pbxproj @@ -8,6 +8,10 @@ /* Begin PBXBuildFile section */ 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 */; }; + 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 */; }; 40E347BE29452CF1003A19B9 /* PlusApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E347BD29452CF1003A19B9 /* PlusApp.swift */; }; @@ -21,6 +25,9 @@ /* Begin PBXFileReference section */ 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 = ""; }; + 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 = ""; }; 40E347BF29452CF1003A19B9 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -35,6 +42,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 402A5F32296B835B00A43DB3 /* Tabler in Frameworks */, 40B51E45294549DF0047377A /* GroutUI in Frameworks */, 40E347D329452E4B003A19B9 /* Compactor in Frameworks */, 40EBE1CC29635306004B9189 /* GroutUI in Frameworks */, @@ -76,6 +84,9 @@ children = ( 40E347BD29452CF1003A19B9 /* PlusApp.swift */, 40E347BF29452CF1003A19B9 /* ContentView.swift */, + 402A5F2E296B7F7B00A43DB3 /* HistoryView.swift */, + 408FACFD296CABC400D02C9C /* RoutineRunList.swift */, + 40245EF5296BE7D4007B5DAB /* ExerciseRunList.swift */, 40E347C129452CF1003A19B9 /* Assets.xcassets */, 40EBCFDE294BFB880082A172 /* Info.plist */, 40E347C329452CF1003A19B9 /* App.entitlements */, @@ -113,6 +124,7 @@ 40B51E44294549DF0047377A /* GroutUI */, 40CCF3F529454D2B007DDE69 /* GroutLib */, 40EBE1CB29635306004B9189 /* GroutUI */, + 402A5F31296B835B00A43DB3 /* Tabler */, ); productName = "Gym Routine Tracker Plus"; productReference = 40E347BA29452CF1003A19B9 /* Gym Routine Tracker Plus.app */; @@ -145,6 +157,7 @@ packageReferences = ( 40E347D129452E4B003A19B9 /* XCRemoteSwiftPackageReference "SwiftCompactor" */, 40EBE1CA29635306004B9189 /* XCRemoteSwiftPackageReference "GroutUI" */, + 402A5F30296B835B00A43DB3 /* XCRemoteSwiftPackageReference "SwiftTabler" */, ); productRefGroup = 40E347BB29452CF1003A19B9 /* Products */; projectDirPath = ""; @@ -174,6 +187,9 @@ buildActionMask = 2147483647; files = ( 40E347C029452CF1003A19B9 /* ContentView.swift in Sources */, + 408FACFE296CAC9300D02C9C /* RoutineRunList.swift in Sources */, + 40245EF6296BE7D4007B5DAB /* ExerciseRunList.swift in Sources */, + 402A5F2F296B7FE000A43DB3 /* HistoryView.swift in Sources */, 40E347BE29452CF1003A19B9 /* PlusApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -297,7 +313,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Sources/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_ASSET_PATHS = "\"Sources/Preview Content\""; DEVELOPMENT_TEAM = J735QC5U38; ENABLE_HARDENED_RUNTIME = YES; @@ -320,7 +336,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.4; + MARKETING_VERSION = 1.5; PRODUCT_BUNDLE_IDENTIFIER = org.openalloc.grout.plus; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -340,7 +356,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Sources/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_ASSET_PATHS = "\"Sources/Preview Content\""; DEVELOPMENT_TEAM = J735QC5U38; ENABLE_HARDENED_RUNTIME = YES; @@ -363,7 +379,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.4; + MARKETING_VERSION = 1.5; PRODUCT_BUNDLE_IDENTIFIER = org.openalloc.grout.plus; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -400,6 +416,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 402A5F30296B835B00A43DB3 /* XCRemoteSwiftPackageReference "SwiftTabler" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/openalloc/SwiftTabler.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.9.6; + }; + }; 40E347D129452E4B003A19B9 /* XCRemoteSwiftPackageReference "SwiftCompactor" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/openalloc/SwiftCompactor.git"; @@ -419,6 +443,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 402A5F31296B835B00A43DB3 /* Tabler */ = { + isa = XCSwiftPackageProductDependency; + package = 402A5F30296B835B00A43DB3 /* XCRemoteSwiftPackageReference "SwiftTabler" */; + productName = Tabler; + }; 40B51E44294549DF0047377A /* GroutUI */ = { isa = XCSwiftPackageProductDependency; productName = GroutUI; diff --git a/README.md b/README.md index 63768c0..3e0a727 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,22 @@ -# Gym Routine Tracker Plus +# Gym Routine Tracker+ _A minimalist gym workout tracker, for iPhone and iPad_ ## Download -Available as a free download in the App Store [HERE](https://apps.apple.com/us/app/gym-routine-tracker/id6444747204). +Available as a FREE download in the App Store [GRT for iPhone/iPad](https://apps.apple.com/us/app/gym-routine-tracker/id1662243916) ## Features -- LARGE text in RUN mode, for those with presbyopia. Leave your cheaters in your locker! -- Prioritizes convenience, quick interactions, and the basic needs of the recreational fitness user. -- Available separately, an independent GRT watchOS app +- _NEW in 1.5_ Logs routine/exercise completions, where history can be reviewed on your iPhone/iPad. + +- LARGE text in RUN mode, for the farsighted. Leave your glasses in your locker! +- Simple data model of user-defined routines and their exercises. +- Your data syncs with your private iCloud account when a network connection is available. +- Fully open source where code is licensed with Mozilla Public License 2.0. +- Available separately as an independent app for the Apple Watch. + +GRT prioritizes convenience, quick interactions, and the basic needs of the recreational fitness user. ### Quick and easy setup @@ -25,12 +31,17 @@ Available as a free download in the App Store [HERE](https://apps.apple.com/us/a - Convenient skip to the next incomplete exercise, in case a machine isn’t immediately available. - Control screen showing the time elapsed since starting the routine. -### App features +### History features (NEW) -- Simple data model of user-defined routines and their exercises. -- Your data syncs with CloudKit when a network connection is available. -- Fully open source where code is licensed with Mozilla Public License 2.0. -- App available as a free download in the iOS App Store. +- Completion of routine/exercise is automatically logged to your private iCloud account. +- Logging can be disabled in settings. +- For the watchOS app, recent history will be stored locally for up to 1 year. Periodically run iOS app for long-term storage and review. +- History can be reviewed on the iOS app for the iPhone/iPad. + +### iCloud Sync + +- Your data automatically syncs with your private iCloud account when a network connection is available. +- That synced data available to the _Gym Routine Tracker_ app running on your other devices. ## Requirements @@ -46,12 +57,20 @@ To any Apple product managers who like this app, please consider Sherlocking it! ## See Also +### App Download Links + +* [GRT for Apple Watch](https://apps.apple.com/us/app/gym-routine-tracker/id6444747204) - App Store link for FREE download +* [GRT+ for iPhone/iPad](https://apps.apple.com/us/app/gym-routine-tracker/id1662243916) - App Store link for FREE download + +### Source Code + * [GRT Website](https://gym-routine-tracker.github.io) - Website for GRT -* [GRT on the App Store](https://apps.apple.com/us/app/gym-routine-tracker/id6444747204) - App Store link for free download of GRT +* [GRT for Apple Watch Source](https://github.com/gym-routine-tracker/Gym-Routine-Tracker-Watch-App) - watchOS implementation +* [GRT+ for iPhone/iPad Source](https://github.com/gym-routine-tracker/Gym-Routine-Tracker-Plus-App) - iOS implementation * [GroutUI](https://github.com/gym-routine-tracker/GroutUI) - shared UI layer for GRT (watchOS and iOS) * [GroutLib](https://github.com/gym-routine-tracker/GroutLib) - shared business logic and data layer for GRT -Apps by the same author: +### macOS Apps by the same author * [FlowAllocator](https://openalloc.github.io/FlowAllocator/index.html) - portfolio rebalancing tool for macOS * [FlowWorth](https://openalloc.github.io/FlowWorth/index.html) - portfolio valuation and tracking tool for macOS diff --git a/Sources/App.entitlements b/Sources/App.entitlements index aac426e..08ad51e 100644 --- a/Sources/App.entitlements +++ b/Sources/App.entitlements @@ -2,9 +2,12 @@ + aps-environment + development com.apple.developer.icloud-container-identifiers iCloud.org.openalloc.grout + iCloud.org.openalloc.grout.archive com.apple.developer.icloud-services diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f3ce43b..945af76 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -8,14 +8,24 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. // +import CoreData import SwiftUI import GroutLib import GroutUI struct ContentView: View { + @Environment(\.managedObjectContext) private var viewContext + + enum Tabs: Int { + case routines = 0 + case history = 1 + case settings = 2 + } + @SceneStorage("main-tab") private var selectedTab = 0 @SceneStorage("main-routines-nav") private var routinesNavData: Data? + @SceneStorage("main-history-nav") private var historyNavData: Data? @SceneStorage("main-settings-nav") private var settingsNavData: Data? var body: some View { @@ -25,9 +35,19 @@ struct ContentView: View { RoutineList() } .tabItem { - Label("Routines", systemImage: "dumbbell.fill") + Label("Routines", systemImage: "dumbbell") } - .tag(0) + .tag(Tabs.routines.rawValue) + + NavStack(name: "history", + navData: $historyNavData, + routineRunDetail: exerciseRunList) { + HistoryView() + } + .tabItem { + Label("History", systemImage: "fossil.shell") + } + .tag(Tabs.history.rawValue) NavStack(name: "settings", navData: $settingsNavData) { @@ -36,17 +56,26 @@ struct ContentView: View { .tabItem { Label("Settings", systemImage: "gear") } - .tag(1) + .tag(Tabs.settings.rawValue) + } + } - // TODO: history, charts, etc. will be the 'Plus' + // used to inject view into NavStack + @ViewBuilder + private func exerciseRunList(_ routineRunUri: URL) -> some View { + if let zRoutineRun = ZRoutineRun.get(viewContext, forURIRepresentation: routineRunUri), + let archiveStore = PersistenceManager.getArchiveStore(viewContext) + { + ExerciseRunList(zRoutineRun: zRoutineRun, archiveStore: archiveStore) + } else { + Text("Routine Run not available to display detail.") } } } -// TODO: four copies of each routine showing up; should be one! struct ContentView_Previews: PreviewProvider { static var previews: some View { - let ctx = PersistenceManager.preview.container.viewContext + let ctx = PersistenceManager.getPreviewContainer().viewContext let routine = Routine.create(ctx, userOrder: 0) routine.name = "Back & Bicep" let e1 = Exercise.create(ctx, userOrder: 0) diff --git a/Sources/ExerciseRunList.swift b/Sources/ExerciseRunList.swift new file mode 100644 index 0000000..ee6f15b --- /dev/null +++ b/Sources/ExerciseRunList.swift @@ -0,0 +1,244 @@ +// +// ExerciseRunList.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 Compactor +import Tabler + +import GroutLib +import GroutUI + +struct ExerciseRunList: View { + @Environment(\.verticalSizeClass) private var verticalSizeClass + @Environment(\.managedObjectContext) private var viewContext + + typealias Sort = TablerSort + typealias Context = TablerContext + typealias ProjectedValue = ObservedObject.Wrapper + + // MARK: - Parameters + + private var archiveStore: NSPersistentStore + private var zRoutineRun: ZRoutineRun + + init(zRoutineRun: ZRoutineRun, archiveStore: NSPersistentStore) { + self.zRoutineRun = zRoutineRun + self.archiveStore = archiveStore + + let predicate = NSPredicate(format: "zRoutineRun = %@", zRoutineRun) + let sortDescriptors = [NSSortDescriptor(keyPath: \ZExerciseRun.completedAt, ascending: true)] + let request = makeRequest(ZExerciseRun.self, + predicate: predicate, + sortDescriptors: sortDescriptors, + inStore: archiveStore) + + _exerciseRuns = FetchRequest(fetchRequest: request) + } + + // MARK: - Locals + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: ExerciseRunList.self)) + + private let columnSpacing: CGFloat = 10 + + private var columnPadding: EdgeInsets { + EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + // EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5) + } + + @FetchRequest private var exerciseRuns: FetchedResults + + private var listConfig: TablerListConfig { + TablerListConfig( + onDelete: deleteAction + ) + } + + private var gridItems: [GridItem] { [ + GridItem(.flexible(minimum: 70), spacing: columnSpacing, alignment: .leading), + GridItem(.flexible(minimum: 120), spacing: columnSpacing, alignment: .leading), + GridItem(.flexible(minimum: 80), spacing: columnSpacing, alignment: .trailing), + ] } + + private let df: DateFormatter = { + let df = DateFormatter() + df.dateStyle = .short + df.timeStyle = .short + return df + }() + + private let tc = TimeCompactor(ifZero: "", style: .full, roundSmallToWhole: false) + + // MARK: - Views + + var body: some View { + TablerList(listConfig, + header: header, + footer: footer, + row: listRow, + rowBackground: rowBackground, + results: exerciseRuns) + .listStyle(.plain) + .navigationTitle(navigationTitle) + } + + private func header(ctx _: Binding) -> some View { + LazyVGrid(columns: gridItems, alignment: .leading) { + Text("Elapsed") + .padding(columnPadding) + Text("Exercise") + .padding(columnPadding) + Text("Intensity") + .padding(columnPadding) + } + } + + @ViewBuilder + private func listRow(element: ZExerciseRun) -> some View { + LazyVGrid(columns: gridItems, alignment: .leading) { + elapsedText(element.completedAt) + .padding(columnPadding) + Text(element.zExercise?.name ?? "") + .padding(columnPadding) + intensityText(element.intensity) + .padding(columnPadding) + } + } + + @ViewBuilder + private func footer(ctx _: Binding) -> some View { + HStack { + GroupBox { + startedAtText + .lineLimit(1) + } label: { + Text("Started") + .foregroundStyle(.tint) + .padding(.bottom, 3) + } + GroupBox { + durationText(zRoutineRun.duration) + .lineLimit(1) + } label: { + Text("Duration") + .foregroundStyle(.tint) + .padding(.bottom, 3) + } + } + } + + private func rowBackground(_: ZExerciseRun) -> some View { + EntityBackground(exerciseColorDarkBg) + } + + private var startedAtText: some View { + VStack { + if let startedAt = zRoutineRun.startedAt, + let dateStr = df.string(from: startedAt) + { + Text(dateStr) + } else { + EmptyView() + } + } + } + + private func elapsedText(_ completedAt: Date?) -> some View { + ElapsedTimeText(elapsedSecs: getDuration(completedAt) ?? 0, timeElapsedFormat: timeElapsedFormat) + } + + private func intensityText(_ intensity: Float) -> some View { + Text(formatIntensity(intensity)) + .modify { + if #available(iOS 16.1, watchOS 9.1, *) { + $0.fontDesign(.monospaced) + } else { + $0.monospaced() + } + } + } + + private func durationText(_ duration: TimeInterval) -> some View { + Text(tc.string(from: duration as NSNumber) ?? "") + } + + // MARK: - Properties + + // select a formatter to accommodate the duration + private var timeElapsedFormat: TimeElapsedFormat { + let secondsPerHour: TimeInterval = 3600 + return zRoutineRun.duration < secondsPerHour ? .mm_ss : .hh_mm_ss + } + + private var navigationTitle: String { + zRoutineRun.zRoutine?.wrappedName ?? "UNKNOWN" + } + + // MARK: - Actions + + private func deleteAction(at offsets: IndexSet) { + for index in offsets { + let element = exerciseRuns[index] + viewContext.delete(element) + } + do { + try viewContext.save() + } catch { + logger.error("\(#function): \(error.localizedDescription)") + } + } + + // MARK: - Helpers + + private func formatIntensity(_ intensity: Float) -> String { + String(format: "%0.1f", intensity) + } + + private func getDuration(_ completedAt: Date?) -> TimeInterval? { + guard let startedAt = zRoutineRun.startedAt, + let completedAt + else { return nil } + + return completedAt.timeIntervalSince(startedAt) + } +} + +struct ExerciseRunList_Previews: PreviewProvider { + static var previews: some View { + let ctx = PersistenceManager.getPreviewContainer().viewContext + let archiveStore = PersistenceManager.getArchiveStore(ctx)! + + let routineArchiveID = UUID() + let startedAt1 = Date.now.addingTimeInterval(-20000) + let duration1 = 500.0 + let zR = ZRoutine.create(ctx, routineName: "blah", routineArchiveID: routineArchiveID, toStore: archiveStore) + let zRR = ZRoutineRun.create(ctx, zRoutine: zR, startedAt: startedAt1, duration: duration1, toStore: archiveStore) + let exerciseArchiveID1 = UUID() + let exerciseArchiveID2 = UUID() + let completedAt1 = startedAt1.addingTimeInterval(116) + let completedAt2 = completedAt1.addingTimeInterval(173) + let intensity1: Float = 150.0 + let intensity2: Float = 200.0 + let zE1 = ZExercise.create(ctx, zRoutine: zR, exerciseName: "Lat Pulldown", exerciseArchiveID: exerciseArchiveID1, toStore: archiveStore) + let zE2 = ZExercise.create(ctx, zRoutine: zR, exerciseName: "Rear Delt", exerciseArchiveID: exerciseArchiveID2, toStore: archiveStore) + _ = ZExerciseRun.create(ctx, zRoutineRun: zRR, zExercise: zE1, completedAt: completedAt1, intensity: intensity1, toStore: archiveStore) + _ = ZExerciseRun.create(ctx, zRoutineRun: zRR, zExercise: zE2, completedAt: completedAt2, intensity: intensity2, toStore: archiveStore) + try! ctx.save() + + return NavigationStack { + ExerciseRunList(zRoutineRun: zRR, archiveStore: archiveStore) + .environment(\.managedObjectContext, ctx) + } + } +} diff --git a/Sources/HistoryView.swift b/Sources/HistoryView.swift new file mode 100644 index 0000000..ea6909e --- /dev/null +++ b/Sources/HistoryView.swift @@ -0,0 +1,117 @@ +// +// HistoryView.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 HistoryView: View { + @Environment(\.managedObjectContext) private var viewContext + @EnvironmentObject private var router: MyRouter + + // MARK: - Parameters + + // MARK: - Locals + + @State private var showAlert = false + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: HistoryView.self)) + + // MARK: - Views + + var body: some View { + RoutineRunList(archiveStore: archiveStore) + .toolbar { + ToolbarItem(placement: .destructiveAction) { + Button(action: { showAlert = true }) { + Text("Clear") + } + } + } + .alert("Are you sure?", + isPresented: $showAlert, + actions: { + Button("Delete", role: .destructive, action: clearHistoryAction) + }, + message: { + Text("This will remove all historical data.") + }) + .navigationTitle(navigationTitle) + .task(priority: .userInitiated, taskAction) + } + + // MARK: - Properties + + private var navigationTitle: String { + "History" + } + + private var archiveStore: NSPersistentStore { + guard let store = PersistenceManager.getArchiveStore(viewContext) + else { + fatalError("unable to resolve archive store") + } + return store + } + + // MARK: - Actions + + private func clearHistoryAction() { + do { + try PersistenceManager.clearZEntities(viewContext, inStore: archiveStore) + try viewContext.save() + } catch { + logger.error("\(#function): \(error.localizedDescription)") + } + } + + @Sendable + private func taskAction() async { + logger.notice("\(#function) START") + + // transfer any 'Z' records from the 'Main' store to the 'Archive' store. + + await PersistenceManager.shared.container.performBackgroundTask { backgroundContext in + do { + try transferToArchive(backgroundContext) + try backgroundContext.save() + } catch { + logger.error("\(#function): TRANSFER \(error.localizedDescription)") + } + } + logger.notice("\(#function) END") + } +} + +struct HistoryView_Previews: PreviewProvider { + static var previews: some View { + let ctx = PersistenceManager.getPreviewContainer().viewContext + let archiveStore = PersistenceManager.getArchiveStore(ctx)! + + let routineArchiveID = UUID() + let startedAt1 = Date.now.addingTimeInterval(-20000) + let duration1 = 500.0 + let startedAt2 = Date.now.addingTimeInterval(-10000) + let duration2 = 400.0 + let zR = ZRoutine.create(ctx, routineName: "blah", routineArchiveID: routineArchiveID, toStore: archiveStore) + _ = ZRoutineRun.create(ctx, zRoutine: zR, startedAt: startedAt1, duration: duration1, toStore: archiveStore) + _ = ZRoutineRun.create(ctx, zRoutine: zR, startedAt: startedAt2, duration: duration2, toStore: archiveStore) + try! ctx.save() + + return NavigationStack { + HistoryView() + .environment(\.managedObjectContext, ctx) + } + } +} diff --git a/Sources/PlusApp.swift b/Sources/PlusApp.swift index 72196e0..fc93358 100644 --- a/Sources/PlusApp.swift +++ b/Sources/PlusApp.swift @@ -22,6 +22,11 @@ struct Gym_Routine_Tracker_Plus_App: App { @Environment(\.scenePhase) var scenePhase + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: "App" + ) + var body: some Scene { WindowGroup { ContentView() @@ -31,7 +36,11 @@ struct Gym_Routine_Tracker_Plus_App: App { } .onChange(of: scenePhase) { _ in // save if: (1) app moved to background, and (2) changes are pending - persistenceManager.save() + do { + try persistenceManager.container.viewContext.save() + } catch { + logger.error("\(#function): \(error.localizedDescription)") + } } } } diff --git a/Sources/RoutineRunList.swift b/Sources/RoutineRunList.swift new file mode 100644 index 0000000..b9b26a9 --- /dev/null +++ b/Sources/RoutineRunList.swift @@ -0,0 +1,175 @@ +// +// RoutineRunList.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 Compactor +import Tabler + +import GroutLib +import GroutUI + +struct RoutineRunList: View { + @Environment(\.verticalSizeClass) private var verticalSizeClass + @Environment(\.colorScheme) private var colorScheme + @Environment(\.managedObjectContext) private var viewContext + @EnvironmentObject private var router: MyRouter + + typealias Sort = TablerSort + typealias Context = TablerContext + typealias ProjectedValue = ObservedObject.Wrapper + + // MARK: - Parameters + + private var archiveStore: NSPersistentStore + + internal init(archiveStore: NSPersistentStore) { + self.archiveStore = archiveStore + + let sortDescriptors = [NSSortDescriptor(keyPath: \ZRoutineRun.startedAt, ascending: false)] + let request = makeRequest(ZRoutineRun.self, + sortDescriptors: sortDescriptors, + inStore: archiveStore) + _routineRuns = FetchRequest(fetchRequest: request) + } + + // MARK: - Locals + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: RoutineRunList.self)) + + private let columnSpacing: CGFloat = 10 + + private var columnPadding: EdgeInsets { + EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + // EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5) + } + + private let df: DateFormatter = { + let df = DateFormatter() + df.dateStyle = .short + df.timeStyle = .none + return df + }() + + @FetchRequest private var routineRuns: FetchedResults + + private var listConfig: TablerListConfig { + TablerListConfig( + onDelete: deleteAction + ) + } + + private var gridItems: [GridItem] { [ + GridItem(.flexible(minimum: 180), spacing: columnSpacing, alignment: .leading), + GridItem(.flexible(minimum: 70), spacing: columnSpacing, alignment: .leading), + GridItem(.flexible(minimum: 70), spacing: columnSpacing, alignment: .leading), + ] } + + private let tc = TimeCompactor(ifZero: "", style: .medium, roundSmallToWhole: false) + + // MARK: - Views + + var body: some View { + TablerList(listConfig, + header: header, + row: listRow, + rowBackground: rowBackground, + results: routineRuns) + .listStyle(.plain) + } + + private func header(ctx _: Binding) -> some View { + LazyVGrid(columns: gridItems, alignment: .leading) { + Text("Routine") + .padding(columnPadding) + Text("Date") + .padding(columnPadding) + Text("Duration") + .padding(columnPadding) + } + } + + @ViewBuilder + private func listRow(element: ZRoutineRun) -> some View { + Button(action: { detailAction(zRoutineRun: element) }) { + LazyVGrid(columns: gridItems, alignment: .leading) { + Text(element.zRoutine?.name ?? "") + .lineLimit(1) + .padding(columnPadding) + startedAtText(element.startedAt) + .lineLimit(1) + .padding(columnPadding) + durationText(element.duration) + .lineLimit(1) +// ElapsedTimeText(elapsedSecs: element.duration) + .padding(columnPadding) + } + .frame(maxWidth: .infinity) + } + } + + private func rowBackground(_: ZRoutineRun) -> some View { + EntityBackground(routineColor) + } + + private func startedAtText(_ date: Date?) -> some View { + guard let date else { return Text("") } + return Text(df.string(from: date)) + } + + private func durationText(_ duration: TimeInterval) -> some View { + Text(tc.string(from: duration as NSNumber) ?? "") + } + + // MARK: - Properties + + // MARK: - Actions + + private func detailAction(zRoutineRun: ZRoutineRun) { + router.path.append(MyRoutes.routineRunDetail(zRoutineRun.uriRepresentation)) + } + + private func deleteAction(at offsets: IndexSet) { + for index in offsets { + let element = routineRuns[index] + viewContext.delete(element) + } + do { + try viewContext.save() + } catch { + logger.error("\(#function): \(error.localizedDescription)") + } + } +} + +struct RoutineRunList_Previews: PreviewProvider { + static var previews: some View { + let ctx = PersistenceManager.getPreviewContainer().viewContext + let archiveStore = PersistenceManager.getArchiveStore(ctx)! + let routineArchiveID = UUID() + + let startedAt1 = Date.now.addingTimeInterval(-20000) + let duration1 = 500.0 + let startedAt2 = Date.now.addingTimeInterval(-10000) + let duration2 = 400.0 + let zR = ZRoutine.create(ctx, routineName: "Chest & Shoulder", routineArchiveID: routineArchiveID, toStore: archiveStore) + _ = ZRoutineRun.create(ctx, zRoutine: zR, startedAt: startedAt1, duration: duration1, toStore: archiveStore) + _ = ZRoutineRun.create(ctx, zRoutine: zR, startedAt: startedAt2, duration: duration2, toStore: archiveStore) + try! ctx.save() + + return NavigationStack { + RoutineRunList(archiveStore: archiveStore) + .environment(\.managedObjectContext, ctx) + } + } +}