diff --git a/BeeKit/Managers/GoalManager.swift b/BeeKit/Managers/GoalManager.swift index 6d93a1fc4..89bccb01e 100644 --- a/BeeKit/Managers/GoalManager.swift +++ b/BeeKit/Managers/GoalManager.swift @@ -82,10 +82,17 @@ import SwiftyJSON logger.notice("Goals unknown, doing full fetch") // We must fetch the user object first, and then fetch goals afterwards, to guarantee User.updated_at is // a safe timestamp for future fetches without losing data - let userResponse = JSON(try await requestManager.get(url: "api/v1/users/{username}.json")!) - let goalResponse = JSON( - try await requestManager.get(url: "api/v1/users/{username}/goals.json", parameters: ["emaciated": "true"])! - ) + guard let getUser = try await requestManager.get(url: "api/v1/users/{username}.json") else { + throw GoalManagerError.getUserFailed + } + let userResponse = JSON(getUser) + guard + let getGoals = try await requestManager.get( + url: "api/v1/users/{username}/goals.json", + parameters: ["emaciated": "true"] + ) + else { throw GoalManagerError.getGoalsFailed } + let goalResponse = JSON(getGoals) // The user may have logged out during the network operation. If so we have nothing to do modelContext.refreshAllObjects() @@ -97,17 +104,18 @@ import SwiftyJSON let goalsToDelete = user.goals.filter { !allGoalIds.contains($0.id) } for goal in goalsToDelete { modelContext.delete(goal) } } - updateGoalsFromJson(goalResponse) + try updateGoalsFromJson(goalResponse) } /// Perform an incremental refresh of goals for regular updates private func refreshGoalsIncremental(user: User) async throws { logger.notice("Doing incremental update since \(user.updatedAt, privacy: .public)") - let userResponse = JSON( - try await requestManager.get( + guard + let getUser = try await requestManager.get( url: "api/v1/users/{username}.json", parameters: ["diff_since": user.updatedAt.timeIntervalSince1970 + 1, "emaciated": "true"] - )! - ) + ) + else { throw GoalManagerError.getUserFailed } + let userResponse = JSON(getUser) let goalResponse = userResponse["goals"] let deletedGoals = userResponse["deleted_goals"] // The user may have logged out during the network operation. If so we have nothing to do @@ -118,52 +126,58 @@ import SwiftyJSON let deletedGoalIds = Set(deletedGoals.arrayValue.map { $0["id"].stringValue }) let goalsToDelete = user.goals.filter { deletedGoalIds.contains($0.id) } for goal in goalsToDelete { modelContext.delete(goal) } - updateGoalsFromJson(goalResponse) + try updateGoalsFromJson(goalResponse) // Update lastUpdatedLocal for all goals, even those not in response let now = Date() for goal in user.goals { goal.lastUpdatedLocal = now } } - public func refreshGoal(_ goalID: NSManagedObjectID) async throws { - let goal = try modelContext.existingObject(with: goalID) as! Goal - - let responseObject = try await requestManager.get( - url: "/api/v1/users/\(currentUserManager.username!)/goals/\(goal.slug)", - parameters: ["datapoints_count": "5", "emaciated": "true"] - ) - let goalJSON = JSON(responseObject!) - + guard let goal = try modelContext.existingObject(with: goalID) as? Goal else { + throw GoalManagerError.refreshGoalFailed(goalID: goalID, reason: "goal not found") + } + guard + let responseObject = try await requestManager.get( + url: "/api/v1/users/\(goal.owner.username)/goals/\(goal.slug)", + parameters: ["datapoints_count": "5", "emaciated": "true"] + ) + else { throw GoalManagerError.getGoalFailed(goalname: goal.slug, goalID: goal.id) } + let goalJSON = JSON(responseObject) // The goal may have changed during the network operation, reload latest version modelContext.refresh(goal, mergeChanges: false) goal.updateToMatch(json: goalJSON) - try modelContext.save() - await performPostGoalUpdateBookkeeping() } - public func forceAutodataRefresh(_ goal: Goal) async throws { let _ = try await requestManager.get( - url: "/api/v1/users/\(currentUserManager.username!)/goals/\(goal.slug)/refresh_graph.json" + url: "/api/v1/users/\(goal.owner.username)/goals/\(goal.slug)/refresh_graph.json" ) } - private func updateGoalsFromJson(_ responseJSON: JSON) { - guard let responseGoals = responseJSON.array else { return } + private func updateGoalsFromJson(_ responseJSON: JSON) throws { + guard let responseGoals = responseJSON.array else { + logger.error("responseJSON apparently not array") + return + } - // The user may have logged out while waiting for the data, so ignore if so - guard let user = self.currentUserManager.user(context: modelContext) else { return } + guard let user = self.currentUserManager.user(context: modelContext) else { + logger.info("The user may have logged out while waiting for the data, so ignore if so") + return + } // Create and update existing goals for goalJSON in responseGoals { - let goalId = goalJSON["id"].stringValue + guard let goalId = goalJSON["id"].string else { + logger.error("goalJSON missing id") + continue + } let request = NSFetchRequest(entityName: "Goal") request.predicate = NSPredicate(format: "id == %@", goalId) - // TODO: Better error handling of failure here? - if let existingGoal = try! modelContext.fetch(request).first { + + if let existingGoal = try modelContext.fetch(request).first { existingGoal.updateToMatch(json: goalJSON) } else { - let _ = Goal(context: modelContext, owner: user, json: goalJSON) + _ = Goal(context: modelContext, owner: user, json: goalJSON) } } @@ -222,3 +236,25 @@ import SwiftyJSON // TODO: Delete from CoreData } } + +extension GoalManager { + fileprivate enum GoalManagerError: Error { + case getUserFailed + case getGoalsFailed + case getGoalFailed(goalname: String, goalID: String) + case refreshGoalFailed(goalID: NSManagedObjectID, reason: String) + } +} + +extension GoalManager.GoalManagerError: LocalizedError { + public var errorDescription: String? { + switch self { + case .getGoalsFailed: return NSLocalizedString("Failed to get goals", comment: "getGoalsFailed") + case .getUserFailed: return NSLocalizedString("Failed to get user", comment: "getUserFailed") + case .getGoalFailed(let goalname, let goalID): + return NSLocalizedString("Failed to get goal: \(goalname) with id: \(goalID)", comment: "getGoalFailed") + case .refreshGoalFailed(let goalID, let reason): + return NSLocalizedString("Failed to refresh goal: \(goalID) because: \(reason)", comment: "refreshGoalFailed") + } + } +} diff --git a/BeeKit/Managers/VersionManager.swift b/BeeKit/Managers/VersionManager.swift index 9a48371f8..e809a37f3 100644 --- a/BeeKit/Managers/VersionManager.swift +++ b/BeeKit/Managers/VersionManager.swift @@ -56,9 +56,10 @@ public class VersionManager { return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String } private func checkIfUpdateRequired() async throws -> Bool { - let responseJSON = try await requestManager.get(url: "api/private/app_versions.json") + guard let responseJSON = try await requestManager.get(url: "api/private/app_versions.json"), + let response = JSON(responseJSON).dictionary + else { throw VersionError.invalidServerResponse } - guard let response = JSON(responseJSON!).dictionary else { throw VersionError.invalidServerResponse } guard let minVersion = response["min_ios"]?.number?.decimalValue else { throw VersionError.noMinimumVersion } minRequiredVersion = "\(minVersion)" diff --git a/BeeKit/Model/BeeminderModel.xcdatamodeld/BeeminderModel.xcdatamodel/contents b/BeeKit/Model/BeeminderModel.xcdatamodeld/BeeminderModel.xcdatamodel/contents index bf3dece92..cc994d00a 100644 --- a/BeeKit/Model/BeeminderModel.xcdatamodeld/BeeminderModel.xcdatamodel/contents +++ b/BeeKit/Model/BeeminderModel.xcdatamodeld/BeeminderModel.xcdatamodel/contents @@ -1,10 +1,10 @@ - + - + @@ -14,13 +14,12 @@ - - + @@ -45,9 +44,8 @@ - + - diff --git a/BeeKitTests/MigrationTests.swift b/BeeKitTests/MigrationTests.swift index 4d417060e..ed62bca14 100644 --- a/BeeKitTests/MigrationTests.swift +++ b/BeeKitTests/MigrationTests.swift @@ -5,9 +5,9 @@ import XCTest class MigrationTests: XCTestCase { private struct TestData { - static let userLastModified = Date(timeIntervalSince1970: 1_600_000_000) - static let goalLastModified = Date(timeIntervalSince1970: 1_610_000_000) - static let dataPointLastModified = Date(timeIntervalSince1970: 1_620_000_000) + static let userLastModified = Date(timeIntervalSince1970: 0) + static let goalLastModified = Date(timeIntervalSince1970: 0) + static let dataPointLastModified = Date(timeIntervalSince1970: 0) } override func tearDown() { super.tearDown() @@ -23,7 +23,7 @@ class MigrationTests: XCTestCase { } } // Creates a CoreData store with the old model version (v1) - private func createStoreWithOldModel() -> URL { + private func createStoreWithb54Model() -> URL { let storeURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent( "TestMigration_\(UUID().uuidString).sqlite" ) @@ -52,8 +52,6 @@ class MigrationTests: XCTestCase { user.setValue("testuser", forKey: "username") user.setValue("America/Los_Angeles", forKey: "timezone") user.setValue(false, forKey: "deadbeat") - user.setValue(Date(), forKey: "updatedAt") - user.setValue(TestData.userLastModified, forKey: "lastModifiedLocal") // Create goal with minimal required fields let goal = NSEntityDescription.insertNewObject(forEntityName: "Goal", into: context) @@ -68,15 +66,13 @@ class MigrationTests: XCTestCase { goal.setValue(0, forKey: field) } for field in ["hhmmFormat", "queued", "todayta", "useDefaults", "won"] { goal.setValue(false, forKey: field) } - goal.setValue(DueByDictionary(), forKey: "dueBy") - goal.setValue(TestData.goalLastModified, forKey: "lastModifiedLocal") goal.setValue(user, forKey: "owner") // Create datapoint let dataPoint = NSEntityDescription.insertNewObject(forEntityName: "DataPoint", into: context) dataPoint.setValue("dp1", forKey: "id") dataPoint.setValue("20230101", forKey: "daystampRaw") dataPoint.setValue(NSDecimalNumber(value: 1.0), forKey: "value") - dataPoint.setValue(TestData.dataPointLastModified, forKey: "lastModifiedLocal") + dataPoint.setValue(goal, forKey: "goal") try context.save() return storeURL @@ -89,7 +85,7 @@ class MigrationTests: XCTestCase { func testLastUpdatedLocalMigration() throws { DueByTableValueTransformer.register() - let storeURL = createStoreWithOldModel() + let storeURL = createStoreWithb54Model() let container = BeeminderPersistentContainer(name: "BeeminderModel") let description = NSPersistentStoreDescription(url: storeURL) container.persistentStoreDescriptions = [description] @@ -106,43 +102,42 @@ class MigrationTests: XCTestCase { let userRequest = NSFetchRequest(entityName: "User") let users = try context.fetch(userRequest) XCTAssertEqual(users.count, 1, "Should have one user after migration") - if let user = users.first { - XCTAssertEqual( - user.lastUpdatedLocal.timeIntervalSince1970, - TestData.userLastModified.timeIntervalSince1970, - accuracy: 0.001, - "User date value should be preserved during migration" - ) - } + let user = try XCTUnwrap(users.first) + XCTAssertEqual( + user.lastUpdatedLocal.timeIntervalSince1970, + TestData.userLastModified.timeIntervalSince1970, + accuracy: 0.001, + "User date value should be preserved during migration" + ) // Migration on Goal let goalRequest = NSFetchRequest(entityName: "Goal") let goals = try context.fetch(goalRequest) XCTAssertEqual(goals.count, 1, "Should have one goal after migration") - if let goal = goals.first { - XCTAssertEqual( - goal.lastUpdatedLocal.timeIntervalSince1970, - TestData.goalLastModified.timeIntervalSince1970, - accuracy: 0.001, - "Goal date value should be preserved during migration" - ) - } + let goal = try XCTUnwrap(goals.first) + XCTAssertEqual( + goal.lastUpdatedLocal.timeIntervalSince1970, + TestData.goalLastModified.timeIntervalSince1970, + accuracy: 0.001, + "Goal date value should be preserved during migration" + ) + // Migration on DataPoint let dataPointRequest = NSFetchRequest(entityName: "DataPoint") let dataPoints = try context.fetch(dataPointRequest) XCTAssertEqual(dataPoints.count, 1, "Should have one data point after migration") - if let dataPoint = dataPoints.first { - XCTAssertEqual( - dataPoint.lastUpdatedLocal.timeIntervalSince1970, - TestData.dataPointLastModified.timeIntervalSince1970, - accuracy: 0.001, - "DataPoint date value should be preserved during migration" - ) - } + let dataPoint = try XCTUnwrap(dataPoints.first) + XCTAssertEqual( + dataPoint.lastUpdatedLocal.timeIntervalSince1970, + TestData.dataPointLastModified.timeIntervalSince1970, + accuracy: 0.001, + "DataPoint date value should be preserved during migration" + ) + } func testAutodataConfigMigration() throws { DueByTableValueTransformer.register() - let storeURL = createStoreWithOldModel() + let storeURL = createStoreWithb54Model() let container = BeeminderPersistentContainer(name: "BeeminderModel") let description = NSPersistentStoreDescription(url: storeURL) container.persistentStoreDescriptions = [description] diff --git a/BeeSwift/Components/DatapointValueAccessory.swift b/BeeSwift/Components/DatapointValueAccessory.swift index e19e77c01..d1379e2c4 100644 --- a/BeeSwift/Components/DatapointValueAccessory.swift +++ b/BeeSwift/Components/DatapointValueAccessory.swift @@ -85,7 +85,7 @@ class DatapointValueAccessory: UIInputView { @objc func colonButtonPressed() { guard let valueField = self.valueField else { return } - valueField.text = "\(valueField.text!):" + valueField.text = valueField.text?.appending(":") } } diff --git a/BeeSwift/Components/UI/UIFontExtension.swift b/BeeSwift/Components/UI/UIFontExtension.swift index b41ee1951..bbcd93ce3 100644 --- a/BeeSwift/Components/UI/UIFontExtension.swift +++ b/BeeSwift/Components/UI/UIFontExtension.swift @@ -11,10 +11,18 @@ import UIKit extension UIFont { public struct beeminder { - public static var defaultFont: UIFont { return defaultFontLight } - public static var defaultFontLight: UIFont { return UIFont(name: "Avenir-Light", size: 18)! } - public static var defaultFontHeavy: UIFont { return UIFont(name: "Avenir-Heavy", size: 18)! } - public static var defaultBoldFont: UIFont { return UIFont(name: "Avenir-Black", size: 18)! } - public static var defaultFontPlain: UIFont { return UIFont(name: "Avenir", size: 18)! } + public static var defaultFont: UIFont = { defaultFontLight }() + public static var defaultFontLight: UIFont = { + UIFont(name: "Avenir-Light", size: 18) ?? .systemFont(ofSize: 18, weight: .light) + }() + public static var defaultFontHeavy: UIFont = { + UIFont(name: "Avenir-Heavy", size: 18) ?? .systemFont(ofSize: 18, weight: .heavy) + }() + public static var defaultBoldFont: UIFont = { + UIFont(name: "Avenir-Black", size: 18) ?? .systemFont(ofSize: 18, weight: .black) + }() + public static var defaultFontPlain: UIFont = { + UIFont(name: "Avenir", size: 18) ?? .systemFont(ofSize: 18, weight: .regular) + }() } }