From e73a8b198e4aa1aa19b11dfd88c6c2c3570eb62c Mon Sep 17 00:00:00 2001 From: William Brawner Date: Sun, 1 Nov 2020 11:54:19 -0700 Subject: [PATCH] Build out most of the UI and functionality Kinda forgot to commit as I went. Better late than never I guess --- .gitignore | 1 + IntervalTimer.xcodeproj/project.pbxproj | 44 +++- IntervalTimer/ActiveTimerView.swift | 111 +++++++++ IntervalTimer/CollapsibleFormRowView.swift | 45 ++++ IntervalTimer/ContentView.swift | 21 -- IntervalTimer/DurationPicker.swift | 103 ++++++++ IntervalTimer/Extensions.swift | 38 +++ IntervalTimer/IntervalTimer.swift | 111 +++++++++ .../IntervalTimer.xcdatamodel/contents | 18 +- IntervalTimer/SceneDelegate.swift | 3 +- IntervalTimer/TimerDataStore.swift | 235 ++++++++++++++++++ IntervalTimer/TimerFormView.swift | 188 ++++++++++++++ IntervalTimer/TimerListView.swift | 81 ++++++ 13 files changed, 968 insertions(+), 31 deletions(-) create mode 100644 .gitignore create mode 100644 IntervalTimer/ActiveTimerView.swift create mode 100644 IntervalTimer/CollapsibleFormRowView.swift delete mode 100644 IntervalTimer/ContentView.swift create mode 100644 IntervalTimer/DurationPicker.swift create mode 100644 IntervalTimer/Extensions.swift create mode 100644 IntervalTimer/IntervalTimer.swift create mode 100644 IntervalTimer/TimerDataStore.swift create mode 100644 IntervalTimer/TimerFormView.swift create mode 100644 IntervalTimer/TimerListView.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6151d34 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +IntervalTimer.xcodeproj/xcuserdata diff --git a/IntervalTimer.xcodeproj/project.pbxproj b/IntervalTimer.xcodeproj/project.pbxproj index dea9d68..0a1fef7 100644 --- a/IntervalTimer.xcodeproj/project.pbxproj +++ b/IntervalTimer.xcodeproj/project.pbxproj @@ -7,15 +7,22 @@ objects = { /* Begin PBXBuildFile section */ + 80465F802543932100710741 /* TimerDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80465F7F2543932100710741 /* TimerDataStore.swift */; }; + 80465F82254395D700710741 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80465F81254395D700710741 /* Extensions.swift */; }; + 80465F862544774700710741 /* CollapsibleFormRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80465F852544774700710741 /* CollapsibleFormRowView.swift */; }; + 80465F8825447ACE00710741 /* DurationPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80465F8725447ACE00710741 /* DurationPicker.swift */; }; + 80465F8A2547A74700710741 /* ActiveTimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80465F892547A74700710741 /* ActiveTimerView.swift */; }; 80F8B6F5254276380024077E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F8B6F4254276380024077E /* AppDelegate.swift */; }; 80F8B6F7254276380024077E /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F8B6F6254276380024077E /* SceneDelegate.swift */; }; 80F8B6FA254276380024077E /* IntervalTimer.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 80F8B6F8254276380024077E /* IntervalTimer.xcdatamodeld */; }; - 80F8B6FC254276380024077E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F8B6FB254276380024077E /* ContentView.swift */; }; + 80F8B6FC254276380024077E /* TimerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F8B6FB254276380024077E /* TimerListView.swift */; }; 80F8B6FE2542763A0024077E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 80F8B6FD2542763A0024077E /* Assets.xcassets */; }; 80F8B7012542763A0024077E /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 80F8B7002542763A0024077E /* Preview Assets.xcassets */; }; 80F8B7042542763A0024077E /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 80F8B7022542763A0024077E /* LaunchScreen.storyboard */; }; 80F8B70F2542763B0024077E /* IntervalTimerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F8B70E2542763B0024077E /* IntervalTimerTests.swift */; }; 80F8B71A2542763B0024077E /* IntervalTimerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F8B7192542763B0024077E /* IntervalTimerUITests.swift */; }; + 80F8B728254276E70024077E /* IntervalTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F8B727254276E70024077E /* IntervalTimer.swift */; }; + 80F8B72A254312820024077E /* TimerFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F8B729254312820024077E /* TimerFormView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -36,11 +43,16 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 80465F7F2543932100710741 /* TimerDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerDataStore.swift; sourceTree = ""; }; + 80465F81254395D700710741 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; + 80465F852544774700710741 /* CollapsibleFormRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleFormRowView.swift; sourceTree = ""; }; + 80465F8725447ACE00710741 /* DurationPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationPicker.swift; sourceTree = ""; }; + 80465F892547A74700710741 /* ActiveTimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveTimerView.swift; sourceTree = ""; }; 80F8B6F1254276380024077E /* IntervalTimer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IntervalTimer.app; sourceTree = BUILT_PRODUCTS_DIR; }; 80F8B6F4254276380024077E /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 80F8B6F6254276380024077E /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 80F8B6F9254276380024077E /* IntervalTimer.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = IntervalTimer.xcdatamodel; sourceTree = ""; }; - 80F8B6FB254276380024077E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 80F8B6FB254276380024077E /* TimerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerListView.swift; sourceTree = ""; }; 80F8B6FD2542763A0024077E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 80F8B7002542763A0024077E /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 80F8B7032542763A0024077E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -51,6 +63,8 @@ 80F8B7152542763B0024077E /* IntervalTimerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IntervalTimerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 80F8B7192542763B0024077E /* IntervalTimerUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntervalTimerUITests.swift; sourceTree = ""; }; 80F8B71B2542763B0024077E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 80F8B727254276E70024077E /* IntervalTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntervalTimer.swift; sourceTree = ""; }; + 80F8B729254312820024077E /* TimerFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerFormView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -103,12 +117,19 @@ children = ( 80F8B6F4254276380024077E /* AppDelegate.swift */, 80F8B6F6254276380024077E /* SceneDelegate.swift */, - 80F8B6FB254276380024077E /* ContentView.swift */, + 80F8B6FB254276380024077E /* TimerListView.swift */, 80F8B6FD2542763A0024077E /* Assets.xcassets */, 80F8B7022542763A0024077E /* LaunchScreen.storyboard */, 80F8B7052542763B0024077E /* Info.plist */, 80F8B6F8254276380024077E /* IntervalTimer.xcdatamodeld */, 80F8B6FF2542763A0024077E /* Preview Content */, + 80F8B727254276E70024077E /* IntervalTimer.swift */, + 80F8B729254312820024077E /* TimerFormView.swift */, + 80465F7F2543932100710741 /* TimerDataStore.swift */, + 80465F81254395D700710741 /* Extensions.swift */, + 80465F852544774700710741 /* CollapsibleFormRowView.swift */, + 80465F8725447ACE00710741 /* DurationPicker.swift */, + 80465F892547A74700710741 /* ActiveTimerView.swift */, ); path = IntervalTimer; sourceTree = ""; @@ -202,7 +223,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1150; - LastUpgradeCheck = 1150; + LastUpgradeCheck = 1200; ORGANIZATIONNAME = "William Brawner"; TargetAttributes = { 80F8B6F0254276380024077E = { @@ -271,9 +292,16 @@ buildActionMask = 2147483647; files = ( 80F8B6FA254276380024077E /* IntervalTimer.xcdatamodeld in Sources */, + 80465F802543932100710741 /* TimerDataStore.swift in Sources */, 80F8B6F5254276380024077E /* AppDelegate.swift in Sources */, - 80F8B6FC254276380024077E /* ContentView.swift in Sources */, + 80465F82254395D700710741 /* Extensions.swift in Sources */, + 80F8B6FC254276380024077E /* TimerListView.swift in Sources */, + 80F8B728254276E70024077E /* IntervalTimer.swift in Sources */, + 80465F862544774700710741 /* CollapsibleFormRowView.swift in Sources */, + 80465F8825447ACE00710741 /* DurationPicker.swift in Sources */, + 80F8B72A254312820024077E /* TimerFormView.swift in Sources */, 80F8B6F7254276380024077E /* SceneDelegate.swift in Sources */, + 80465F8A2547A74700710741 /* ActiveTimerView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -346,6 +374,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -370,7 +399,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.5; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -406,6 +435,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -424,7 +454,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.5; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; diff --git a/IntervalTimer/ActiveTimerView.swift b/IntervalTimer/ActiveTimerView.swift new file mode 100644 index 0000000..caa811f --- /dev/null +++ b/IntervalTimer/ActiveTimerView.swift @@ -0,0 +1,111 @@ +// +// ActiveTimerView.swift +// IntervalTimer +// +// Created by William Brawner on 10/26/20. +// Copyright © 2020 William Brawner. All rights reserved. +// + +import SwiftUI + +struct ActiveTimerView: View { + @EnvironmentObject var dataStore: TimerDataStore + + var body: some View { + if let state = dataStore.activeTimer { + VStack { + Spacer() + Text(state.phase.rawValue) + Text(state.timeRemaining.toDurationString()) + .font(Font.system(size: 200).monospacedDigit()) + .lineLimit(1) + .scaledToFit() + .minimumScaleFactor(0.1) + .padding() + TimerControlsView(dataStore: self.dataStore, timerRunning: .constant(state.isRunning)) + Spacer() + HStack { + LabeledCounter(label: "Sets", counter: state.currentSet) + Spacer() + LabeledCounter(label: "Round", counter: state.currentRound) + } + } + .navigationBarTitle("\(state.timer.name)", displayMode: .inline) + .background(state.backgroundColor) + .edgesIgnoringSafeArea(.vertical) + .animation(.default) + } + } +} + +struct TimerControlsView: View { + let dataStore: TimerDataStore + @Binding var timerRunning: Bool + private var toggleButtonImage: String { + get { + if self.timerRunning { + return "pause.fill" + } else { + return "play.fill" + } + } + } + var buttonSize: CGFloat = 32 + + var body: some View { + HStack { + Button(action: { + self.dataStore.goBack() + }, label: { + Image(systemName: "backward.end.fill") + .resizable() + .scaledToFit() + .frame(width: buttonSize, height: buttonSize) + }) + .foregroundColor(.primary) + .padding() + Button(action: { + self.dataStore.toggle() + }, label: { + Image(systemName: toggleButtonImage) + .resizable() + .scaledToFit() + .frame(width: buttonSize, height: buttonSize) + }) + .foregroundColor(.primary) + .padding() + Button(action: { + self.dataStore.goForward() + }, label: { + Image(systemName: "forward.end.fill") + .resizable() + .scaledToFit() + .frame(width: buttonSize, height: buttonSize) + }) + .foregroundColor(.primary) + .padding() + } + } +} + +struct LabeledCounter: View { + let label: String + let counter: Int64 + + var body: some View { + VStack { + Text(label) + .multilineTextAlignment(.center) + Text(String(counter)) + .multilineTextAlignment(.center) + .font(Font.title.monospacedDigit()) + } + .padding() + } +} + +struct ActiveTimerView_Previews: PreviewProvider { + static var previews: some View { + ActiveTimerView().environmentObject(TimerDataStore() {}) + } +} diff --git a/IntervalTimer/CollapsibleFormRowView.swift b/IntervalTimer/CollapsibleFormRowView.swift new file mode 100644 index 0000000..42762b5 --- /dev/null +++ b/IntervalTimer/CollapsibleFormRowView.swift @@ -0,0 +1,45 @@ +// +// CollapsibleFormRowView.swift +// IntervalTimer +// +// Created by William Brawner on 10/24/20. +// Copyright © 2020 William Brawner. All rights reserved. +// + +import SwiftUI + +struct CollapsibleFormRowView: View { + let rowTitle: String + let rowValue: String + let buttonAction: () -> Void + @Binding var expandView: Bool + let collapsibleView: AnyView + + // This is a hacky workaround to get multiple if statements inside a single form + var body: some View { + VStack { + Button(action: { + withAnimation { + self.buttonAction() + } + }, label: { + HStack { + Text(rowTitle) + Spacer() + Text(rowValue) + .font(Font.body.monospacedDigit()) + .foregroundColor(.secondary) + } + }).foregroundColor(.primary) + if expandView { + collapsibleView + } + } + } +} + +struct CollapsibleFormRowView_Previews: PreviewProvider { + static var previews: some View { + CollapsibleFormRowView(rowTitle: "Sets", rowValue: "2", buttonAction: {}, expandView: .constant(true), collapsibleView: Text("Expanded").toAnyView()) + } +} diff --git a/IntervalTimer/ContentView.swift b/IntervalTimer/ContentView.swift deleted file mode 100644 index 2cb06ef..0000000 --- a/IntervalTimer/ContentView.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// ContentView.swift -// IntervalTimer -// -// Created by William Brawner on 10/22/20. -// Copyright © 2020 William Brawner. All rights reserved. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - Text("Hello, World!") - } -} - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } -} diff --git a/IntervalTimer/DurationPicker.swift b/IntervalTimer/DurationPicker.swift new file mode 100644 index 0000000..c1caf04 --- /dev/null +++ b/IntervalTimer/DurationPicker.swift @@ -0,0 +1,103 @@ +// +// DurationPicker.swift +// IntervalTimer +// +// Created by William Brawner on 10/24/20. +// Copyright © 2020 William Brawner. All rights reserved. +// + +import SwiftUI +import Combine + +struct DurationPicker: View { + @State fileprivate var hours: Int64 { + didSet { + updateDuration() + } + } + @State fileprivate var minutes: Int64 { + didSet { + updateDuration() + } + } + @State fileprivate var seconds: Int64 { + didSet { + updateDuration() + } + } + @Binding var selection: Int64 + + var body: some View { + GeometryReader { geometry in + HStack { + Picker("Hours", selection: self.$hours) { + ForEach(0..<24) { hour in + Text(String(format: "%02d", hour)).tag(Int64(hour)) + } + } + .onReceive(Just(self.hours)) { _ in + self.updateDuration() + } + .frame(width: geometry.size.width/3, alignment: .center) + .clipped() + .pickerStyle(WheelPickerStyle()) + .labelsHidden() + Picker("Minutes", selection: self.$minutes) { + ForEach(0..<60) { minute in + Text(String(format: "%02d", minute)).tag(Int64(minute)) + } + } + .onReceive(Just(self.minutes)) { _ in + self.updateDuration() + } + .frame(width: geometry.size.width/3, alignment: .center) + .clipped() + .pickerStyle(WheelPickerStyle()) + .labelsHidden() + Picker("Seconds", selection: self.$seconds) { + ForEach(0..<60) { second in + Text(String(format: "%02d", second)).tag(Int64(second)) + } + } + .onReceive(Just(self.seconds)) { _ in + self.updateDuration() + } + .frame(width: geometry.size.width/3, alignment: .center) + .clipped() + .pickerStyle(WheelPickerStyle()) + .labelsHidden() + } + }.frame(height: 200) + } + + init (_ selection: Binding) { + self._selection = selection + var seconds: Int64 = selection.wrappedValue + var hours: Int64 = 0 + if (seconds >= 3600) { + hours = seconds / 3600 + seconds -= hours * 3600 + } + + var minutes: Int64 = 0 + if (seconds >= 60) { + minutes = seconds / 60 + seconds -= minutes * 60 + } + self._hours = State(initialValue: hours) + self._minutes = State(initialValue: minutes) + self._seconds = State(initialValue: seconds) + } +} + +extension DurationPicker { + func updateDuration() { + self.selection = (self.hours * 3600) + (self.minutes * 60) + self.seconds + } +} + +struct DurationPicker_Previews: PreviewProvider { + static var previews: some View { + DurationPicker(.constant(0)) + } +} diff --git a/IntervalTimer/Extensions.swift b/IntervalTimer/Extensions.swift new file mode 100644 index 0000000..18fadb8 --- /dev/null +++ b/IntervalTimer/Extensions.swift @@ -0,0 +1,38 @@ +// +// Extensions.swift +// IntervalTimer +// +// Created by William Brawner on 10/23/20. +// Copyright © 2020 William Brawner. All rights reserved. +// + +import Foundation +import SwiftUI + +extension View { + func toAnyView() -> AnyView { + return AnyView(self) + } +} + +extension Int64 { + func toDurationString() -> String { + var seconds: Int64 = self + var hours: Int64 = 0 + if (seconds >= 3600) { + hours = seconds / 3600 + seconds -= hours * 3600 + } + + var minutes: Int64 = 0 + if (seconds >= 60) { + minutes = seconds / 60 + seconds -= minutes * 60 + } + + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%02d:%02d", minutes, seconds) + } +} diff --git a/IntervalTimer/IntervalTimer.swift b/IntervalTimer/IntervalTimer.swift new file mode 100644 index 0000000..ee3b14c --- /dev/null +++ b/IntervalTimer/IntervalTimer.swift @@ -0,0 +1,111 @@ +// +// Timer.swift +// IntervalTimer +// +// Created by William Brawner on 10/22/20. +// Copyright © 2020 William Brawner. All rights reserved. +// + +import Foundation +import CoreData +import SwiftUI + +class IntervalTimer: NSManagedObject, Identifiable { + @NSManaged var id: UUID? + @NSManaged var name: String + @NSManaged var userDescription: String? + @NSManaged var warmUpDuration: Int64 + @NSManaged var lowIntensityDuration: Int64 + @NSManaged var highIntensityDuration: Int64 + @NSManaged var restDuration: Int64 + @NSManaged var cooldownDuration: Int64 + @NSManaged var sets: Int64 + @NSManaged var rounds: Int64 +} + +enum Phase: String { + case warmUp = "Warm-Up" + case low = "Low Intensity" + case high = "High Intensity" + case rest = "Rest" + case cooldown = "Cooldown" +} + +extension Phase { + var backgroundColor: Color { + get { + switch self { + case .warmUp: + return Color(UIColor.systemBackground) + case .low: + return Color(UIColor.systemRed) + case .high: + return Color(UIColor.systemGreen) + case .rest: + return Color(UIColor.systemYellow) + case .cooldown: + return Color(UIColor.systemBlue) + } + } + } +} + +extension IntervalTimer { + func durationForPhase(phase: Phase) -> Int64 { + switch phase { + case .warmUp: + return self.warmUpDuration + case .low: + return self.lowIntensityDuration + case .high: + return self.highIntensityDuration + case .rest: + return self.restDuration + case .cooldown: + return self.cooldownDuration + } + } + + var totalDuration: Int64 { + get { + return warmUpDuration + ((((lowIntensityDuration + highIntensityDuration) * sets) + restDuration) * rounds) + cooldownDuration + } + } +} + +struct ActiveTimerState { + let timer: IntervalTimer + let timeRemaining: Int64 + let currentSet: Int64 + let currentRound: Int64 + let soundId: Int? + let phase: Phase + let isRunning: Bool + var backgroundColor: Color { + get { + return self.phase.backgroundColor + } + } +} + +extension ActiveTimerState { + func copy( + timer: IntervalTimer? = nil, + timeRemaining: Int64? = nil, + currentSet: Int64? = nil, + currentRound: Int64? = nil, + soundId: Int? = nil, + phase: Phase? = nil, + isRunning: Bool? = nil + ) -> ActiveTimerState { + return ActiveTimerState( + timer: timer ?? self.timer, + timeRemaining: timeRemaining ?? self.timeRemaining, + currentSet: currentSet ?? self.currentSet, + currentRound: currentRound ?? self.currentRound, + soundId: soundId ?? self.soundId, + phase: phase ?? self.phase, + isRunning: isRunning ?? self.isRunning + ) + } +} diff --git a/IntervalTimer/IntervalTimer.xcdatamodeld/IntervalTimer.xcdatamodel/contents b/IntervalTimer/IntervalTimer.xcdatamodeld/IntervalTimer.xcdatamodel/contents index 23c0b10..ff327da 100644 --- a/IntervalTimer/IntervalTimer.xcdatamodeld/IntervalTimer.xcdatamodel/contents +++ b/IntervalTimer/IntervalTimer.xcdatamodeld/IntervalTimer.xcdatamodel/contents @@ -1,4 +1,18 @@ - - + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/IntervalTimer/SceneDelegate.swift b/IntervalTimer/SceneDelegate.swift index 2ccf3d4..7e52e6c 100644 --- a/IntervalTimer/SceneDelegate.swift +++ b/IntervalTimer/SceneDelegate.swift @@ -24,7 +24,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Create the SwiftUI view and set the context as the value for the managedObjectContext environment keyPath. // Add `@Environment(\.managedObjectContext)` in the views that will need the context. - let contentView = ContentView().environment(\.managedObjectContext, context) + let contentView = TimerListView().environment(\.managedObjectContext, context) + .environmentObject(TimerDataStore() {}) // Use a UIHostingController as window root view controller. if let windowScene = scene as? UIWindowScene { diff --git a/IntervalTimer/TimerDataStore.swift b/IntervalTimer/TimerDataStore.swift new file mode 100644 index 0000000..e005ad4 --- /dev/null +++ b/IntervalTimer/TimerDataStore.swift @@ -0,0 +1,235 @@ +// +// TimerDataStore.swift +// IntervalTimer +// +// Created by William Brawner on 10/23/20. +// Copyright © 2020 William Brawner. All rights reserved. +// + +import Combine +import CoreData +import Foundation +import SwiftUI + +class TimerDataStore: ObservableObject { + private var persistentContainer: NSPersistentContainer + private let internalTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + private var timerCancellable: AnyCancellable? = nil + + @Published var activeTimer: ActiveTimerState? = nil { + didSet { + self.hasActiveTimer = self.activeTimer != nil + } + } + @Published var hasActiveTimer: Bool = false + @Published var timers: Result<[IntervalTimer], TimerError> = .failure(.loading) + + func openTimer(_ timer: IntervalTimer) { + self.activeTimer = ActiveTimerState( + timer: timer, + timeRemaining: timer.warmUpDuration, + currentSet: timer.sets, + currentRound: timer.rounds, + soundId: nil, + phase: Phase.warmUp, + isRunning: false + ) + } + + func closeTimer() { + self.activeTimer = nil + } + + func goBack() { + guard let state = self.activeTimer else { + return + } + + switch state.phase { + case .warmUp: + self.activeTimer = state.copy( + timeRemaining: state.timer.warmUpDuration + ) + case .low: + if state.currentSet == state.timer.sets && state.currentRound == state.timer.rounds { + self.activeTimer = state.copy( + timeRemaining: state.timer.warmUpDuration, + phase: .warmUp + ) + } else if state.currentSet == state.timer.sets && state.currentRound < state.timer.rounds { + self.activeTimer = state.copy( + timeRemaining: state.timer.restDuration, + phase: .rest + ) + } else { + self.activeTimer = state.copy( + timeRemaining: state.timer.highIntensityDuration, + currentSet: state.currentSet + 1, + phase: .high + ) + } + case .high: + self.activeTimer = state.copy( + timeRemaining: state.timer.lowIntensityDuration, + phase: .low + ) + case .rest: + self.activeTimer = state.copy( + timeRemaining: state.timer.highIntensityDuration, + phase: .high + ) + case .cooldown: + self.activeTimer = state.copy( + timeRemaining: state.timer.highIntensityDuration, + phase: .high + ) + } + } + + func toggle() { + guard let state = self.activeTimer else { + return + } + if self.timerCancellable != nil { + self.timerCancellable?.cancel() + self.timerCancellable = nil + } else { + self.timerCancellable = self.internalTimer.sink(receiveValue: { _ in + self.updateTimer() + }) + } + self.activeTimer = state.copy(isRunning: self.timerCancellable != nil) + UIApplication.shared.isIdleTimerDisabled = self.activeTimer?.isRunning ?? false + } + + func updateTimer() { + guard let state = self.activeTimer else { + return + } + let newState = state.copy(timeRemaining: state.timeRemaining - 1) + if newState.timeRemaining == 0 { + goForward() + } else { + self.activeTimer = newState + } + } + + func goForward() { + guard let state = self.activeTimer else { + return + } + switch state.phase { + case .warmUp: + self.activeTimer = state.copy( + timeRemaining: state.timer.lowIntensityDuration, + phase: .low + ) + case .low: + self.activeTimer = state.copy( + timeRemaining: state.timer.highIntensityDuration, + phase: .high + ) + case .high: + if state.currentSet > 1 { + self.activeTimer = state.copy( + timeRemaining: state.timer.lowIntensityDuration, + currentSet: state.currentSet - 1, + phase: .low + ) + } else if state.currentRound > 1 { + self.activeTimer = state.copy( + timeRemaining: state.timer.restDuration, + currentRound: state.currentRound - 1, + phase: .rest + ) + } else { + self.activeTimer = state.copy( + timeRemaining: state.timer.cooldownDuration, + phase: .cooldown + ) + } + case .rest: + self.activeTimer = state.copy( + timeRemaining: state.timer.lowIntensityDuration, + currentSet: state.timer.sets, + phase: .low + ) + case .cooldown: + self.activeTimer = state.copy( + timeRemaining: 0, + isRunning: false + ) + self.timerCancellable?.cancel() + self.timerCancellable = nil + UIApplication.shared.isIdleTimerDisabled = false + } + } + + func loadTimers() { + DispatchQueue.global(qos: .background).async { + let fetchRequest = NSFetchRequest(entityName: "IntervalTimer") + do { + let fetchedTimers = try self.persistentContainer.viewContext.fetch(fetchRequest) as! [IntervalTimer] + DispatchQueue.main.async { + self.timers = .success(fetchedTimers) + } + } catch { + DispatchQueue.main.async { + self.timers = .failure(.failed(error)) + } + } + } + } + + func saveTimer( + id: UUID? = nil, + name: String, + description: String? = nil, + warmUpDuration: Int64, + lowIntensityDuration: Int64, + highIntensityDuration: Int64, + restDuration: Int64, + cooldownDuration: Int64, + sets: Int64, + rounds: Int64 + ) { + let timer = IntervalTimer.init(entity: NSEntityDescription.entity(forEntityName: "IntervalTimer", in: persistentContainer.viewContext)!, insertInto: persistentContainer.viewContext) as IntervalTimer + timer.id = id ?? UUID() + timer.name = name + timer.userDescription = description + timer.warmUpDuration = warmUpDuration + timer.lowIntensityDuration = lowIntensityDuration + timer.highIntensityDuration = highIntensityDuration + timer.restDuration = restDuration + timer.cooldownDuration = cooldownDuration + timer.sets = sets + timer.rounds = rounds + let viewContext = persistentContainer.viewContext + viewContext.insert(timer) + try! viewContext.save() + loadTimers() + } + + func deleteTimer(_ timer: IntervalTimer) { + let viewContext = persistentContainer.viewContext + viewContext.delete(timer) + try! viewContext.save() + loadTimers() + } + + init(_ completionClosure: @escaping () -> ()) { + persistentContainer = NSPersistentContainer(name: "IntervalTimer") + persistentContainer.loadPersistentStores() { (description, error) in + if let error = error { + fatalError("Failed to load Core Data stack: \(error)") + } + self.loadTimers() + completionClosure() + } + } +} + +enum TimerError: Error { + case loading + case failed(_ error: Error) +} diff --git a/IntervalTimer/TimerFormView.swift b/IntervalTimer/TimerFormView.swift new file mode 100644 index 0000000..6465356 --- /dev/null +++ b/IntervalTimer/TimerFormView.swift @@ -0,0 +1,188 @@ +// +// TimerFormView.swift +// IntervalTimer +// +// Created by William Brawner on 10/23/20. +// Copyright © 2020 William Brawner. All rights reserved. +// + +import SwiftUI + +struct TimerFormView: View { + @Environment(\.presentationMode) var presentationMode + @EnvironmentObject var dataStore: TimerDataStore + let title: String + let timerId: UUID? + @State var name: String + @State var description: String + @State var warmDuration: Int64 + @State var lowDuration: Int64 + @State var highDuration: Int64 + @State var restDuration: Int64 + @State var coolDuration: Int64 + @State var sets: Int64 + @State var rounds: Int64 + @State var activePicker: ActivePicker = .none + + var setsPicker: AnyView { + if self.activePicker == .sets { + return Picker("Sets", selection: self.$sets) { + ForEach(1..<100) { + Text(String($0)).tag(Int64($0)) + } + } + .pickerStyle(WheelPickerStyle()) + .labelsHidden() + .toAnyView() + } else { + return EmptyView().toAnyView() + } + } + + var body: some View { + NavigationView { + Form { + TextField("Name", text: self.$name) + TextField("Description", text: self.$description) + CollapsibleFormRowView( + rowTitle: "Warm-up", + rowValue: "\(warmDuration.toDurationString())", + buttonAction: { + self.activePicker = (self.activePicker == .warmUp) ? .none : .warmUp + }, + expandView: .constant(self.activePicker == .warmUp), + collapsibleView: DurationPicker($warmDuration).toAnyView() + ) + CollapsibleFormRowView( + rowTitle: "Low Intensity", + rowValue: "\(lowDuration.toDurationString())", + buttonAction: { + self.activePicker = (self.activePicker == .lowIntensity) ? .none : .lowIntensity + }, + expandView: .constant(self.activePicker == .lowIntensity), + collapsibleView: DurationPicker($lowDuration).toAnyView() + ) + CollapsibleFormRowView( + rowTitle: "High Intensity", + rowValue: "\(highDuration.toDurationString())", + buttonAction: { + self.activePicker = (self.activePicker == .highIntensity) ? .none : .highIntensity + }, + expandView: .constant(self.activePicker == .highIntensity), + collapsibleView: DurationPicker($highDuration).toAnyView() + ) + CollapsibleFormRowView( + rowTitle: "Rest", + rowValue: "\(restDuration.toDurationString())", + buttonAction: { + self.activePicker = (self.activePicker == .rest) ? .none : .rest + }, + expandView: .constant(self.activePicker == .rest), + collapsibleView: DurationPicker($restDuration).toAnyView() + ) + CollapsibleFormRowView( + rowTitle: "Cooldown", + rowValue: "\(coolDuration.toDurationString())", + buttonAction: { + self.activePicker = (self.activePicker == .cooldown) ? .none : .cooldown + }, + expandView: .constant(self.activePicker == .cooldown), + collapsibleView: DurationPicker($coolDuration).toAnyView() + ) + CollapsibleFormRowView( + rowTitle: "Sets", + rowValue: "\(sets)", + buttonAction: { + self.activePicker = (self.activePicker == .sets) ? .none : .sets + }, + expandView: .constant(self.activePicker == .sets), + collapsibleView: Picker("Sets", selection: $sets) { + ForEach(1..<100) { + Text(String($0)).tag(Int64($0)) + } + } + .pickerStyle(WheelPickerStyle()) + .labelsHidden() + .toAnyView() + ) + CollapsibleFormRowView( + rowTitle: "Rounds", + rowValue: "\(rounds)", + buttonAction: { + self.activePicker = (self.activePicker == .rounds) ? .none : .rounds + }, + expandView: .constant(self.activePicker == .rounds), + collapsibleView: Picker("Rounds", selection: $rounds) { + ForEach(1..<100) { + Text(String($0)).tag(Int64($0)) + } + } + .pickerStyle(WheelPickerStyle()) + .labelsHidden() + .toAnyView() + ) + } + .navigationBarTitle("\(self.title)", displayMode: .inline) + .navigationBarItems(leading: + Button(action: { + self.presentationMode.wrappedValue.dismiss() + }, label: { + Text("Cancel") + }), trailing: + Button(action: { + self.dataStore.saveTimer( + id: self.timerId, + name: self.name, + description: self.description, + warmUpDuration: self.warmDuration, + lowIntensityDuration: self.lowDuration, + highIntensityDuration: self.highDuration, + restDuration: self.restDuration, + cooldownDuration: self.coolDuration, + sets: self.sets, + rounds: self.rounds + ) + self.presentationMode.wrappedValue.dismiss() + }, label: { + Text("Save") + }) + ) + } + } + + init(_ timer: IntervalTimer? = nil) { + self.timerId = timer?.id + if self.timerId != nil { + self.title = "Edit Timer" + } else { + self.title = "New Timer" + } + self._name = State(initialValue: timer?.name ?? "") + self._description = State(initialValue: timer?.userDescription ?? "") + self._warmDuration = State(initialValue: timer?.warmUpDuration ?? 300) + self._lowDuration = State(initialValue: timer?.lowIntensityDuration ?? 30) + self._highDuration = State(initialValue: timer?.highIntensityDuration ?? 60) + self._restDuration = State(initialValue: timer?.restDuration ?? 60) + self._coolDuration = State(initialValue: timer?.cooldownDuration ?? 300) + self._sets = State(initialValue: timer?.sets ?? 4) + self._rounds = State(initialValue: timer?.rounds ?? 2) + } +} + +enum ActivePicker { + case none + case warmUp + case lowIntensity + case highIntensity + case rest + case cooldown + case sets + case rounds +} + +struct TimerFormView_Previews: PreviewProvider { + + static var previews: some View { + TimerFormView().environmentObject(TimerDataStore() {}) + } +} diff --git a/IntervalTimer/TimerListView.swift b/IntervalTimer/TimerListView.swift new file mode 100644 index 0000000..5cd2e27 --- /dev/null +++ b/IntervalTimer/TimerListView.swift @@ -0,0 +1,81 @@ +// +// ContentView.swift +// IntervalTimer +// +// Created by William Brawner on 10/22/20. +// Copyright © 2020 William Brawner. All rights reserved. +// + +import SwiftUI + +struct TimerListView: View { + @State var isEditing: Bool = false + @EnvironmentObject var dataStore: TimerDataStore + + var stateContent: AnyView { + switch dataStore.timers { + case .success(let timers): + if timers.count == 0 { + return Text("Create a timer to get started") + .toAnyView() + } else { + return List(timers) { timer in + NavigationLink( + destination: ActiveTimerView() + .navigationBarBackButtonHidden(true) + .navigationBarItems(leading: Button(action: { + // TODO: Confirm before exiting if the timer isn't complete + self.dataStore.closeTimer() + }, label: { Image(systemName: "xmark") }) + .foregroundColor(.primary) + ), + isActive: .constant(self.dataStore.hasActiveTimer) + ) { + HStack { + VStack(alignment: .leading) { + Text(timer.name) + .lineLimit(1) + if timer.userDescription?.count ?? 0 > 0 { + Text(timer.userDescription!) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + Spacer() + Text(timer.totalDuration.toDurationString()) + .font(Font.subheadline.monospacedDigit()) + .foregroundColor(.secondary) + } + .frame(minHeight: 50) + .contentShape(Rectangle()) + .onTapGesture { + self.dataStore.openTimer(timer) + } + } + }.toAnyView() + } + default: + return Text("Loading...").toAnyView() + } + } + + var body: some View { + NavigationView { + stateContent + .navigationBarTitle("Timers") + .navigationBarItems(trailing: Button(action: { + self.isEditing = true + }, label: { Image(systemName: "plus").padding() } )) + } + .sheet(isPresented: $isEditing, + onDismiss: { self.isEditing = false }, + content: { TimerFormView()}) + } +} + +struct TimerListView_Previews: PreviewProvider { + static var previews: some View { + TimerListView().environmentObject(TimerDataStore() {}) + } +}