diff --git a/BeeSwift.xcodeproj/project.pbxproj b/BeeSwift.xcodeproj/project.pbxproj index f74933ee9..5d4444d53 100644 --- a/BeeSwift.xcodeproj/project.pbxproj +++ b/BeeSwift.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 9B8CA57D24B120CA009C86C2 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9B8CA57C24B120CA009C86C2 /* LaunchScreen.storyboard */; }; + 9BEB2D2B2CF3ED9A00D36ED1 /* GoalViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BEB2D2A2CF3ED9A00D36ED1 /* GoalViewModelTests.swift */; }; + 9BEB2D2D2CF3EF7E00D36ED1 /* GoalViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BEB2D2C2CF3EF7E00D36ED1 /* GoalViewModel.swift */; }; A10D4E931B07948500A72D29 /* DatapointsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10D4E921B07948500A72D29 /* DatapointsTableView.swift */; }; A10DC2DF207BFCBA00FB7B3A /* RemoveHKMetricViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10DC2DE207BFCBA00FB7B3A /* RemoveHKMetricViewController.swift */; }; A11A87C61FEBFF7200A43E47 /* ChooseGoalSortViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A87C51FEBFF7200A43E47 /* ChooseGoalSortViewController.swift */; }; @@ -218,6 +220,8 @@ /* Begin PBXFileReference section */ 9B8CA57C24B120CA009C86C2 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + 9BEB2D2A2CF3ED9A00D36ED1 /* GoalViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalViewModelTests.swift; sourceTree = ""; }; + 9BEB2D2C2CF3EF7E00D36ED1 /* GoalViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalViewModel.swift; sourceTree = ""; }; A10D4E921B07948500A72D29 /* DatapointsTableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatapointsTableView.swift; sourceTree = ""; }; A10DC2DE207BFCBA00FB7B3A /* RemoveHKMetricViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveHKMetricViewController.swift; sourceTree = ""; }; A11A87C51FEBFF7200A43E47 /* ChooseGoalSortViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseGoalSortViewController.swift; sourceTree = ""; }; @@ -514,6 +518,7 @@ E43BEA852A036D4300FC3A38 /* LogReaderTests.swift */, E417572C2A6446FE0029CDDA /* CurrentUserManagerTests.swift */, E4B6FEC52A776A2900690376 /* GoalTests.swift */, + 9BEB2D2A2CF3ED9A00D36ED1 /* GoalViewModelTests.swift */, ); path = BeeSwiftTests; sourceTree = ""; @@ -605,6 +610,7 @@ children = ( A1F9D1E9211B9B7600E2BC93 /* EditDatapointViewController.swift */, A1BD0D171AEB30A5001EDE8B /* GoalViewController.swift */, + 9BEB2D2C2CF3EF7E00D36ED1 /* GoalViewModel.swift */, A11BC2D81FFAD5BC00E56064 /* TimerViewController.swift */, ); name = GoalView; @@ -1013,6 +1019,7 @@ A1453B3F1AEDFCC8006F48DA /* SignInViewController.swift in Sources */, A1E618E41E7934C700D8ED93 /* HealthKitConfigTableViewCell.swift in Sources */, E4B083392932F90400A71564 /* ConfigureHKMetricViewController.swift in Sources */, + 9BEB2D2D2CF3EF7E00D36ED1 /* GoalViewModel.swift in Sources */, E43BEA842A036A9C00FC3A38 /* LogReader.swift in Sources */, A196CB1F1AE4142F00B90A3E /* GalleryViewController.swift in Sources */, A1BE73AA1E8B45BF00DEC4DB /* ChooseHKMetricViewController.swift in Sources */, @@ -1054,6 +1061,7 @@ E4B0A32E28C194C800055EA7 /* AddDataIntents.intentdefinition in Sources */, E48E2714296B75E4008013C0 /* TotalSleepMinutesTests.swift in Sources */, A196CB331AE4142F00B90A3E /* BeeSwiftTests.swift in Sources */, + 9BEB2D2B2CF3ED9A00D36ED1 /* GoalViewModelTests.swift in Sources */, E4B6FEC62A776A2900690376 /* GoalTests.swift in Sources */, E43BEA862A036D4300FC3A38 /* LogReaderTests.swift in Sources */, ); diff --git a/BeeSwift.xcodeproj/xcshareddata/xcschemes/BeeSwift.xcscheme b/BeeSwift.xcodeproj/xcshareddata/xcschemes/BeeSwift.xcscheme index 9423f1050..65fc24094 100644 --- a/BeeSwift.xcodeproj/xcshareddata/xcschemes/BeeSwift.xcscheme +++ b/BeeSwift.xcodeproj/xcshareddata/xcschemes/BeeSwift.xcscheme @@ -26,7 +26,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> diff --git a/BeeSwift/GoalViewController.swift b/BeeSwift/GoalViewController.swift index a07f27a26..4f3187646 100644 --- a/BeeSwift/GoalViewController.swift +++ b/BeeSwift/GoalViewController.swift @@ -21,8 +21,8 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl let buttonHeight = 42 private let logger = Logger(subsystem: "com.beeminder.com", category: "GoalViewController") - - let goal: Goal + + private let viewModel: GoalViewModel fileprivate var goalImageView = GoalImageView(isThumbnail: false) fileprivate var datapointTableController = DatapointTableViewController() @@ -44,7 +44,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl private var date: Date = Date() init(goal: Goal) { - self.goal = goal + self.viewModel = .init(goal: goal) super.init(nibName: nil, bundle: nil) } @@ -54,7 +54,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl override func viewDidLoad() { self.view.backgroundColor = UIColor.systemBackground - self.title = self.goal.slug + self.title = viewModel.title // have to set these before the datapoints since setting the most recent datapoint updates the text field, // which in turn updates the stepper @@ -124,7 +124,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl make.left.equalTo(self.goalImageScrollView) make.right.equalTo(self.goalImageScrollView) } - self.goalImageView.goal = self.goal + self.goalImageView.goal = self.viewModel.goal self.addChild(self.datapointTableController) self.scrollView.addSubview(self.datapointTableController.view) @@ -136,7 +136,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl } let dataEntryView = UIView() - dataEntryView.isHidden = self.goal.hideDataEntry + dataEntryView.isHidden = viewModel.isDataEntryHidden self.scrollView.addSubview(dataEntryView) dataEntryView.snp.makeConstraints { (make) -> Void in @@ -212,7 +212,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl self.dateStepper.tintColor = UIColor.Beeminder.gray dataEntryView.addSubview(self.dateStepper) self.dateStepper.addTarget(self, action: #selector(GoalViewController.dateStepperValueChanged), for: .valueChanged) - self.dateStepper.value = Self.makeInitialDateStepperValue(for: goal) + self.dateStepper.value = viewModel.initialDateStepperValue() self.dateStepper.snp.makeConstraints { (make) -> Void in make.top.equalTo(self.dateTextField.snp.bottom).offset(elementSpacing) @@ -257,15 +257,11 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl make.bottom.equalTo(self.submitButton) } - if self.goal.isDataProvidedAutomatically { + if viewModel.showPullToRefreshHint { let pullToRefreshView = PullToRefreshView() scrollView.addSubview(pullToRefreshView) - if self.goal.isLinkedToHealthKit { - pullToRefreshView.message = "Pull down to synchronize with Apple Health" - } else { - pullToRefreshView.message = "Pull down to update" - } + pullToRefreshView.message = viewModel.pullToRefreshHint pullToRefreshView.snp.makeConstraints { (make) in make.top.equalTo(self.datapointTableController.view.snp.bottom).offset(elementSpacing) @@ -275,7 +271,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl } self.navigationItem.rightBarButtonItems = [UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(self.actionButtonPressed))] - if !self.goal.hideDataEntry { + if viewModel.showTimerButton { self.navigationItem.rightBarButtonItems?.append(UIBarButtonItem(image: UIImage(systemName: "stopwatch"), style: .plain, target: self, action: #selector(self.timerButtonPressed))) } @@ -304,21 +300,21 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl do { try await self.updateGoalAndInterface() } catch { - logger.error("Error refreshing details for goal \(self.goal.slug): \(error)") + logger.error("Error refreshing details for goal \(self.viewModel.goalName): \(error)") } } } @objc func timerButtonPressed() { - let controller = TimerViewController(goal: self.goal) + let controller = TimerViewController(goal: viewModel.goal) controller.modalPresentationStyle = .fullScreen self.present(controller, animated: true, completion: nil) } @objc func actionButtonPressed() { - let username = goal.owner.username + let username = viewModel.username guard let accessToken = ServiceLocator.currentUserManager.accessToken, - let viewGoalUrl = URL(string: "\(ServiceLocator.requestManager.baseURLString)/api/v1/users/\(username).json?access_token=\(accessToken)&redirect_to_url=\(ServiceLocator.requestManager.baseURLString)/\(username)/\(self.goal.slug)") else { return } + let viewGoalUrl = URL(string: "\(ServiceLocator.requestManager.baseURLString)/api/v1/users/\(username).json?access_token=\(accessToken)&redirect_to_url=\(ServiceLocator.requestManager.baseURLString)/\(username)/\(viewModel.goalName)") else { return } let safariVC = SFSafariViewController(url: viewGoalUrl) safariVC.delegate = self @@ -328,12 +324,12 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl @objc func refreshButtonPressed() { Task { @MainActor in do { - if self.goal.isLinkedToHealthKit { - try await ServiceLocator.healthStoreManager.updateWithRecentData(goalID: self.goal.objectID, days: 7) - } else if goal.isDataProvidedAutomatically { + if viewModel.isLinkedWithHealthKit { + try await ServiceLocator.healthStoreManager.updateWithRecentData(goalID: self.viewModel.goalObjectId, days: 7) + } else if !viewModel.usesManualDataEntry { // Don't force a refresh for manual goals. While doing so is harmless, it queues the goal which means we show a // lemniscate for a few seconds, making the refresh slower. - try await ServiceLocator.goalManager.forceAutodataRefresh(self.goal) + try await ServiceLocator.goalManager.forceAutodataRefresh(self.viewModel.goal) } try await self.updateGoalAndInterface() } catch { @@ -348,8 +344,8 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl } @objc func refreshCountdown() { - self.countdownLabel.textColor = self.goal.countdownColor - self.countdownLabel.text = self.goal.capitalSafesum() + self.countdownLabel.textColor = viewModel.countdownLabelTextColor + self.countdownLabel.text = viewModel.countdownLabelText } @objc func goalImageTapped() { @@ -357,10 +353,10 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl } func datapointTableViewController(_ datapointTableViewController: DatapointTableViewController, didSelectDatapoint datapoint: BeeDataPoint) { - guard !self.goal.hideDataEntry else { return } + guard !viewModel.isDataEntryHidden else { return } guard let existingDatapoint = datapoint as? DataPoint else { return } - let editDatapointViewController = EditDatapointViewController(goal: goal, datapoint: existingDatapoint) + let editDatapointViewController = EditDatapointViewController(goal: viewModel.goal, datapoint: existingDatapoint) let navigationController = UINavigationController(rootViewController: editDatapointViewController) navigationController.modalPresentationStyle = .formSheet self.present(navigationController, animated: true, completion: nil) @@ -382,8 +378,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl } func setValueTextField() { - let suggestedNextValue = goal.suggestedNextValue ?? 1 - valueTextField.text = "\(String(describing: suggestedNextValue))" + valueTextField.text = "\(viewModel.suggestedNextValue)" valueTextFieldValueChanged() } @@ -455,7 +450,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl self.scrollView.scrollRectToVisible(CGRect(x: 0, y: 0, width: 0, height: 0), animated: true) do { - let _ = try await ServiceLocator.requestManager.addDatapoint(urtext: self.urtext, slug: self.goal.slug) + let _ = try await ServiceLocator.requestManager.addDatapoint(urtext: self.urtext, slug: viewModel.goalName) self.commentTextField.text = "" try await updateGoalAndInterface() @@ -481,13 +476,13 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl } func updateGoalAndInterface() async throws { - try await ServiceLocator.goalManager.refreshGoal(self.goal.objectID) + try await ServiceLocator.goalManager.refreshGoal(self.viewModel.goalObjectId) updateInterfaceToMatchGoal() } func updateInterfaceToMatchGoal() { - self.datapointTableController.hhmmformat = goal.hhmmFormat - self.datapointTableController.datapoints = goal.recentData.sorted(by: {$0.updatedAt < $1.updatedAt}) + self.datapointTableController.hhmmformat = viewModel.isHhmmFormat + self.datapointTableController.datapoints = viewModel.recentDatapoints self.refreshCountdown() } @@ -495,15 +490,6 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTabl func viewForZooming(in scrollView: UIScrollView) -> UIView? { return self.goalImageView } - - private static func makeInitialDateStepperValue(date: Date = Date(), for goal: Goal) -> Double { - let daystampAccountingForTheGoalsDeadline = Daystamp(fromDate: date, - deadline: goal.deadline) - let daystampAssumingMidnightDeadline = Daystamp(fromDate: date, - deadline: 0) - - return Double(daystampAssumingMidnightDeadline.distance(to: daystampAccountingForTheGoalsDeadline)) - } // MARK: - SFSafariViewControllerDelegate diff --git a/BeeSwift/GoalViewModel.swift b/BeeSwift/GoalViewModel.swift new file mode 100644 index 000000000..d3d034ded --- /dev/null +++ b/BeeSwift/GoalViewModel.swift @@ -0,0 +1,79 @@ +import Foundation + +import CoreData +import BeeKit + +struct GoalViewModel { + let goal: Goal + + var title: String { + goal.slug + } + + var isDataEntryHidden: Bool { + goal.hideDataEntry + } + + var showPullToRefreshHint: Bool { + goal.isDataProvidedAutomatically + } + + var pullToRefreshHint: String { + self.goal.isLinkedToHealthKit + ? "Pull down to synchronize with Apple Health" + : "Pull down to update" + } + + var goalName: String { + goal.slug + } + + var username: String { + goal.owner.username + } + + var countdownLabelTextColor: UIColor? { + goal.countdownColor + } + + var countdownLabelText: String? { + goal.capitalSafesum() + } + + var suggestedNextValue: NSNumber { + goal.suggestedNextValue ?? 1 + } + + var isHhmmFormat: Bool { + goal.hhmmFormat + } + + var recentDatapoints: [DataPoint] { + goal.recentData.sorted(using: SortDescriptor(\.updatedAt)) + } + + var isLinkedWithHealthKit: Bool { + goal.isLinkedToHealthKit + } + + var usesManualDataEntry: Bool { + !goal.isDataProvidedAutomatically + } + + var goalObjectId: NSManagedObjectID { + goal.objectID + } + + var showTimerButton: Bool { + !goal.hideDataEntry + } + + func initialDateStepperValue(submissionDate date: Date = Date()) -> Double { + let daystampAccountingForTheGoalsDeadline = Daystamp(fromDate: date, + deadline: goal.deadline) + let daystampAssumingMidnightDeadline = Daystamp(fromDate: date, + deadline: 0) + + return Double(daystampAssumingMidnightDeadline.distance(to: daystampAccountingForTheGoalsDeadline)) + } +} diff --git a/BeeSwiftTests/GoalViewModelTests.swift b/BeeSwiftTests/GoalViewModelTests.swift new file mode 100644 index 000000000..2856f8eb9 --- /dev/null +++ b/BeeSwiftTests/GoalViewModelTests.swift @@ -0,0 +1,85 @@ + +import XCTest +@testable import BeeSwift +@testable import BeeKit + +final class GoalViewModelTests: XCTestCase { + private enum DayStep: Double { + case previousDay = -1 + case sameDay = 0 + case nextDay = 1 + } + + func testInitialStepperIsYesterdayWhenSubmissionDateIsAfterMidnightAndBeforeDeadline() async throws { + let goalWithAfterMidnightDeadline = Self.makeGoalWithDeadline(3600 * 3) + let viewModel = GoalViewModel(goal: goalWithAfterMidnightDeadline) + let submissionDateBeforeGoalsDeadline = Calendar.current.date(bySettingHour: 1, minute: 30, second: 0, of: Date())! + let actual = viewModel.initialDateStepperValue(submissionDate: submissionDateBeforeGoalsDeadline) + XCTAssertEqual(actual, + DayStep.previousDay.rawValue) + } + + func testInitialStepperIsTodayWhenSubmissionDateIsBeforeMidnightAndBeforeDeadline() async throws { + let goalWithMidnightDeadline = Self.makeGoalWithDeadline(0) + let viewModel = GoalViewModel(goal: goalWithMidnightDeadline) + let submissionDateBeforeMidnight = Calendar.current.date(bySettingHour: 20, minute: 30, second: 0, of: Date())! + let actual = viewModel.initialDateStepperValue(submissionDate: submissionDateBeforeMidnight) + XCTAssertEqual(actual, + DayStep.sameDay.rawValue) + } + + func testInitialStepperIsTomorrowWhenSubmissionDateIsAfterDeadline() async throws { + let goalWithBeforeMidnightDeadline = Self.makeGoalWithDeadline(3600 * -3) + let viewModel = GoalViewModel(goal: goalWithBeforeMidnightDeadline) + let submissionDateBetweenDeadlineAndMidnight = Calendar.current.date(bySettingHour: 22, minute: 30, second: 0, of: Date())! + let actual = viewModel.initialDateStepperValue(submissionDate: submissionDateBetweenDeadlineAndMidnight) + XCTAssertEqual(actual, + DayStep.nextDay.rawValue) + } +} + +private extension GoalViewModelTests { + static func makeGoalWithDeadline(_ deadline: Int) -> Goal { + let context = BeeminderPersistentContainer.createMemoryBackedForTests().newBackgroundContext() + + let user = User(context: context, + username: "user123", + deadbeat: false, + timezone: "", + defaultAlertStart: 0, + defaultDeadline: 0, + defaultLeadTime: 0) + + let goal = Goal(context: context, + owner: user, + id: "goalid", + slug: "goalname", + alertStart: 0, + autodata: nil, + deadline: deadline, + graphUrl: "", + healthKitMetric: "", + hhmmFormat: false, + initDay: 0, + lastTouch: "", + limSum: "", + leadTime: 0, + pledge: 801, + queued: false, + safeBuf: 0, + safeSum: "", + thumbUrl: "", + title: "goaldescription", + todayta: false, + urgencyKey: "urgencyKey", + useDefaults: false, + won: false, + yAxis: "units") + + context.perform { + try! context.save() + } + + return goal + } +}