Build out most of the UI and functionality

Kinda forgot to commit as I went. Better late than never I guess
This commit is contained in:
William Brawner 2020-11-01 11:54:19 -07:00
parent dfa4780bd6
commit e73a8b198e
13 changed files with 968 additions and 31 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
IntervalTimer.xcodeproj/xcuserdata

View file

@ -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 = "<group>"; };
80465F81254395D700710741 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; };
80465F852544774700710741 /* CollapsibleFormRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleFormRowView.swift; sourceTree = "<group>"; };
80465F8725447ACE00710741 /* DurationPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationPicker.swift; sourceTree = "<group>"; };
80465F892547A74700710741 /* ActiveTimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveTimerView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
80F8B6F6254276380024077E /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
80F8B6F9254276380024077E /* IntervalTimer.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = IntervalTimer.xcdatamodel; sourceTree = "<group>"; };
80F8B6FB254276380024077E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
80F8B6FB254276380024077E /* TimerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerListView.swift; sourceTree = "<group>"; };
80F8B6FD2542763A0024077E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
80F8B7002542763A0024077E /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
80F8B7032542763A0024077E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
@ -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 = "<group>"; };
80F8B71B2542763B0024077E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
80F8B727254276E70024077E /* IntervalTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntervalTimer.swift; sourceTree = "<group>"; };
80F8B729254312820024077E /* TimerFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerFormView.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -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;

View file

@ -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() {})
}
}

View file

@ -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())
}
}

View file

@ -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()
}
}

View file

@ -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<Int64>) {
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))
}
}

View file

@ -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)
}
}

View file

@ -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
)
}
}

View file

@ -1,4 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="1" systemVersion="11A491" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="true" userDefinedModelVersionIdentifier="">
<elements/>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17192" systemVersion="19H2" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
<entity name="IntervalTimer" representedClassName=".IntervalTimer" syncable="YES">
<attribute name="cooldownDuration" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="highIntensityDuration" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="lowIntensityDuration" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="restDuration" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rounds" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sets" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="userDescription" optional="YES" attributeType="String"/>
<attribute name="warmUpDuration" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
</entity>
<elements>
<element name="IntervalTimer" positionX="-63" positionY="-18" width="128" height="193"/>
</elements>
</model>

View file

@ -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 {

View file

@ -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<NSFetchRequestResult>(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)
}

View file

@ -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() {})
}
}

View file

@ -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() {})
}
}