* Initial work on presenting alert on error
* Move Account-related error handling up the hierarchy
* Handle errors on logout
* Fix for temporary debugging
* Clean up WriteFreelyModel’s published vars
* Add error handling to top-level content view
* Set current error on API call failures
* Set current error on API call handlers
* Move User Defaults errors to ErrorConstants file
* Add default values for some error strings
* Handle purging post errors
* Add FIXME to track silent failure on fetching collections
As collections are fetched and added to the `list` property in the CollectionListModel’s initializer, it’s tricky to throw an error here: we call it as a property initializer in CollectionListView, which cannot throw.
Consider refactoring this logic such that we’re using, for example, a @FetchRequest in CollectionListView instead.
* Handle errors in (most) shared code
Two outliers to come back to are:
- the LocalStoreManager, where we can’t set a current error in the WriteFreelyModel in methods that can’t throw
- the CollectionListModel, where the initializer can’t throw because we use it as a property initializer in CollectionListView
* Add error handling to Mac app
* Revert "Add error handling to Mac app"
This reverts commit b1a8b8b29c
.
tags/v1.0.10-ios
@@ -2,6 +2,7 @@ import SwiftUI | |||||
struct AccountLoginView: View { | struct AccountLoginView: View { | ||||
@EnvironmentObject var model: WriteFreelyModel | @EnvironmentObject var model: WriteFreelyModel | ||||
@EnvironmentObject var errorHandling: ErrorHandling | |||||
@State private var alertMessage: String = "" | @State private var alertMessage: String = "" | ||||
@State private var username: String = "" | @State private var username: String = "" | ||||
@@ -76,8 +77,7 @@ struct AccountLoginView: View { | |||||
as: username, password: password | as: username, password: password | ||||
) | ) | ||||
} else { | } else { | ||||
model.loginErrorMessage = AccountError.invalidServerURL.localizedDescription | |||||
model.isPresentingLoginErrorAlert = true | |||||
self.errorHandling.handle(error: AccountError.invalidServerURL) | |||||
} | } | ||||
}, label: { | }, label: { | ||||
Text("Log In") | Text("Log In") | ||||
@@ -88,13 +88,6 @@ struct AccountLoginView: View { | |||||
.padding() | .padding() | ||||
} | } | ||||
} | } | ||||
.alert(isPresented: $model.isPresentingLoginErrorAlert) { | |||||
Alert( | |||||
title: Text("Error Logging In"), | |||||
message: Text(model.loginErrorMessage ?? "An unknown error occurred while trying to login."), | |||||
dismissButton: .default(Text("OK")) | |||||
) | |||||
} | |||||
} | } | ||||
} | } | ||||
@@ -2,6 +2,7 @@ import SwiftUI | |||||
struct AccountLogoutView: View { | struct AccountLogoutView: View { | ||||
@EnvironmentObject var model: WriteFreelyModel | @EnvironmentObject var model: WriteFreelyModel | ||||
@EnvironmentObject var errorHandling: ErrorHandling | |||||
@State private var isPresentingLogoutConfirmation: Bool = false | @State private var isPresentingLogoutConfirmation: Bool = false | ||||
@State private var editedPostsWarningString: String = "" | @State private var editedPostsWarningString: String = "" | ||||
@@ -66,7 +67,7 @@ struct AccountLogoutView: View { | |||||
editedPostsWarningString = "You'll lose unpublished changes to \(editedPosts.count) edited posts. " | editedPostsWarningString = "You'll lose unpublished changes to \(editedPosts.count) edited posts. " | ||||
} | } | ||||
} catch { | } catch { | ||||
print("Error: failed to fetch cached posts") | |||||
self.errorHandling.handle(error: LocalStoreError.couldNotFetchPosts("cached")) | |||||
} | } | ||||
self.isPresentingLogoutConfirmation = true | self.isPresentingLogoutConfirmation = true | ||||
} | } | ||||
@@ -1,58 +1,6 @@ | |||||
import SwiftUI | import SwiftUI | ||||
import WriteFreely | import WriteFreely | ||||
enum AccountError: Error { | |||||
case invalidPassword | |||||
case usernameNotFound | |||||
case serverNotFound | |||||
case invalidServerURL | |||||
case couldNotSaveTokenToKeychain | |||||
case couldNotFetchTokenFromKeychain | |||||
case couldNotDeleteTokenFromKeychain | |||||
} | |||||
extension AccountError: LocalizedError { | |||||
public var errorDescription: String? { | |||||
switch self { | |||||
case .serverNotFound: | |||||
return NSLocalizedString( | |||||
"The server could not be found. Please check the information you've entered and try again.", | |||||
comment: "" | |||||
) | |||||
case .invalidPassword: | |||||
return NSLocalizedString( | |||||
"Invalid password. Please check that you've entered your password correctly and try logging in again.", | |||||
comment: "" | |||||
) | |||||
case .usernameNotFound: | |||||
return NSLocalizedString( | |||||
"Username not found. Did you use your email address by mistake?", | |||||
comment: "" | |||||
) | |||||
case .invalidServerURL: | |||||
return NSLocalizedString( | |||||
"Please enter a valid instance domain name. It should look like \"https://example.com\" or \"write.as\".", // swiftlint:disable:this line_length | |||||
comment: "" | |||||
) | |||||
case .couldNotSaveTokenToKeychain: | |||||
return NSLocalizedString( | |||||
"There was a problem trying to save your access token to the device, please try logging in again.", | |||||
comment: "" | |||||
) | |||||
case .couldNotFetchTokenFromKeychain: | |||||
return NSLocalizedString( | |||||
"There was a problem trying to fetch your access token from the device, please try logging in again.", | |||||
comment: "" | |||||
) | |||||
case .couldNotDeleteTokenFromKeychain: | |||||
return NSLocalizedString( | |||||
"There was a problem trying to delete your access token from the device, please try logging out again.", | |||||
comment: "" | |||||
) | |||||
} | |||||
} | |||||
} | |||||
struct AccountModel { | struct AccountModel { | ||||
@AppStorage(WFDefaults.isLoggedIn, store: UserDefaults.shared) var isLoggedIn: Bool = false | @AppStorage(WFDefaults.isLoggedIn, store: UserDefaults.shared) var isLoggedIn: Bool = false | ||||
private let defaults = UserDefaults.shared | private let defaults = UserDefaults.shared | ||||
@@ -2,19 +2,33 @@ import SwiftUI | |||||
struct AccountView: View { | struct AccountView: View { | ||||
@EnvironmentObject var model: WriteFreelyModel | @EnvironmentObject var model: WriteFreelyModel | ||||
@EnvironmentObject var errorHandling: ErrorHandling | |||||
var body: some View { | var body: some View { | ||||
if model.account.isLoggedIn { | if model.account.isLoggedIn { | ||||
HStack { | HStack { | ||||
Spacer() | Spacer() | ||||
AccountLogoutView() | AccountLogoutView() | ||||
.withErrorHandling() | |||||
Spacer() | Spacer() | ||||
} | } | ||||
.padding() | .padding() | ||||
} else { | } else { | ||||
AccountLoginView() | AccountLoginView() | ||||
.withErrorHandling() | |||||
.padding(.top) | .padding(.top) | ||||
} | } | ||||
EmptyView() | |||||
.onChange(of: model.hasError) { value in | |||||
if value { | |||||
if let error = model.currentError { | |||||
self.errorHandling.handle(error: error) | |||||
} else { | |||||
self.errorHandling.handle(error: AppError.genericError()) | |||||
} | |||||
model.hasError = false | |||||
} | |||||
} | |||||
} | } | ||||
} | } | ||||
@@ -0,0 +1,173 @@ | |||||
import Foundation | |||||
// MARK: - Network Errors | |||||
enum NetworkError: Error { | |||||
case noConnectionError | |||||
} | |||||
extension NetworkError: LocalizedError { | |||||
public var errorDescription: String? { | |||||
switch self { | |||||
case .noConnectionError: | |||||
return NSLocalizedString( | |||||
"There is no internet connection at the moment. Please reconnect or try again later.", | |||||
comment: "" | |||||
) | |||||
} | |||||
} | |||||
} | |||||
// MARK: - Keychain Errors | |||||
enum KeychainError: Error { | |||||
case couldNotStoreAccessToken | |||||
case couldNotPurgeAccessToken | |||||
case couldNotFetchAccessToken | |||||
} | |||||
extension KeychainError: LocalizedError { | |||||
public var errorDescription: String? { | |||||
switch self { | |||||
case .couldNotStoreAccessToken: | |||||
return NSLocalizedString("There was a problem storing your access token in the Keychain.", comment: "") | |||||
case .couldNotPurgeAccessToken: | |||||
return NSLocalizedString("Something went wrong purging the token from the Keychain.", comment: "") | |||||
case .couldNotFetchAccessToken: | |||||
return NSLocalizedString("Something went wrong fetching the token from the Keychain.", comment: "") | |||||
} | |||||
} | |||||
} | |||||
// MARK: - Account Errors | |||||
enum AccountError: Error { | |||||
case invalidPassword | |||||
case usernameNotFound | |||||
case serverNotFound | |||||
case invalidServerURL | |||||
case unknownLoginError | |||||
case genericAuthError | |||||
} | |||||
extension AccountError: LocalizedError { | |||||
public var errorDescription: String? { | |||||
switch self { | |||||
case .serverNotFound: | |||||
return NSLocalizedString( | |||||
"The server could not be found. Please check the information you've entered and try again.", | |||||
comment: "" | |||||
) | |||||
case .invalidPassword: | |||||
return NSLocalizedString( | |||||
"Invalid password. Please check that you've entered your password correctly and try logging in again.", | |||||
comment: "" | |||||
) | |||||
case .usernameNotFound: | |||||
return NSLocalizedString( | |||||
"Username not found. Did you use your email address by mistake?", | |||||
comment: "" | |||||
) | |||||
case .invalidServerURL: | |||||
return NSLocalizedString( | |||||
"Please enter a valid instance domain name. It should look like \"https://example.com\" or \"write.as\".", // swiftlint:disable:this line_length | |||||
comment: "" | |||||
) | |||||
case .genericAuthError: | |||||
return NSLocalizedString("Something went wrong, please try logging in again.", comment: "") | |||||
case .unknownLoginError: | |||||
return NSLocalizedString("An unknown error occurred while trying to login.", comment: "") | |||||
} | |||||
} | |||||
} | |||||
// MARK: - User Defaults Errors | |||||
enum UserDefaultsError: Error { | |||||
case couldNotMigrateStandardDefaults | |||||
} | |||||
extension UserDefaultsError: LocalizedError { | |||||
public var errorDescription: String? { | |||||
switch self { | |||||
case .couldNotMigrateStandardDefaults: | |||||
return NSLocalizedString("Could not migrate user defaults to group container", comment: "") | |||||
} | |||||
} | |||||
} | |||||
// MARK: - Local Store Errors | |||||
enum LocalStoreError: Error { | |||||
case couldNotSaveContext | |||||
case couldNotFetchCollections | |||||
case couldNotFetchPosts(String = "") | |||||
case couldNotPurgePosts(String = "") | |||||
case couldNotPurgeCollections | |||||
case couldNotLoadStore(String) | |||||
case couldNotMigrateStore(String) | |||||
case couldNotDeleteStoreAfterMigration(String) | |||||
case genericError(String = "") | |||||
} | |||||
extension LocalStoreError: LocalizedError { | |||||
public var errorDescription: String? { | |||||
switch self { | |||||
case .couldNotSaveContext: | |||||
return NSLocalizedString("Error saving context", comment: "") | |||||
case .couldNotFetchCollections: | |||||
return NSLocalizedString("Failed to fetch blogs from local store.", comment: "") | |||||
case .couldNotFetchPosts(let postFilter): | |||||
if postFilter.isEmpty { | |||||
return NSLocalizedString("Failed to fetch posts from local store.", comment: "") | |||||
} else { | |||||
return NSLocalizedString("Failed to fetch \(postFilter) posts from local store.", comment: "") | |||||
} | |||||
case .couldNotPurgePosts(let postFilter): | |||||
if postFilter.isEmpty { | |||||
return NSLocalizedString("Failed to purge \(postFilter) posts from local store.", comment: "") | |||||
} else { | |||||
return NSLocalizedString("Failed to purge posts from local store.", comment: "") | |||||
} | |||||
case .couldNotPurgeCollections: | |||||
return NSLocalizedString("Failed to purge cached collections", comment: "") | |||||
case .couldNotLoadStore(let errorDescription): | |||||
return NSLocalizedString("Something went wrong loading local store: \(errorDescription)", comment: "") | |||||
case .couldNotMigrateStore(let errorDescription): | |||||
return NSLocalizedString("Something went wrong migrating local store: \(errorDescription)", comment: "") | |||||
case .couldNotDeleteStoreAfterMigration(let errorDescription): | |||||
return NSLocalizedString("Something went wrong deleting old store: \(errorDescription)", comment: "") | |||||
case .genericError(let customContent): | |||||
if customContent.isEmpty { | |||||
return NSLocalizedString("Something went wrong accessing device storage", comment: "") | |||||
} else { | |||||
return NSLocalizedString(customContent, comment: "") | |||||
} | |||||
} | |||||
} | |||||
} | |||||
// MARK: - Application Errors | |||||
enum AppError: Error { | |||||
case couldNotGetLoggedInClient | |||||
case couldNotGetPostId | |||||
case genericError(String = "") | |||||
} | |||||
extension AppError: LocalizedError { | |||||
public var errorDescription: String? { | |||||
switch self { | |||||
case .couldNotGetLoggedInClient: | |||||
return NSLocalizedString("Something went wrong trying to access the WriteFreely client.", comment: "") | |||||
case .couldNotGetPostId: | |||||
return NSLocalizedString("Something went wrong trying to get the post's unique ID.", comment: "") | |||||
case .genericError(let customContent): | |||||
if customContent.isEmpty { | |||||
return NSLocalizedString("Something went wrong", comment: "") | |||||
} else { | |||||
return NSLocalizedString(customContent, comment: "") | |||||
} | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,42 @@ | |||||
// Based on https://www.ralfebert.com/swiftui/generic-error-handling/ | |||||
import SwiftUI | |||||
struct ErrorAlert: Identifiable { | |||||
var id = UUID() | |||||
var message: String | |||||
var dismissAction: (() -> Void)? | |||||
} | |||||
class ErrorHandling: ObservableObject { | |||||
@Published var currentAlert: ErrorAlert? | |||||
func handle(error: Error) { | |||||
currentAlert = ErrorAlert(message: error.localizedDescription) | |||||
} | |||||
} | |||||
struct HandleErrorByShowingAlertViewModifier: ViewModifier { | |||||
@StateObject var errorHandling = ErrorHandling() | |||||
func body(content: Content) -> some View { | |||||
content | |||||
.environmentObject(errorHandling) | |||||
.background( | |||||
EmptyView() | |||||
.alert(item: $errorHandling.currentAlert) { currentAlert in | |||||
Alert(title: Text("Error"), | |||||
message: Text(currentAlert.message), | |||||
dismissButton: .default(Text("OK")) { | |||||
currentAlert.dismissAction?() | |||||
}) | |||||
} | |||||
) | |||||
} | |||||
} | |||||
extension View { | |||||
func withErrorHandling() -> some View { | |||||
modifier(HandleErrorByShowingAlertViewModifier()) | |||||
} | |||||
} |
@@ -17,17 +17,6 @@ enum WFDefaults { | |||||
extension UserDefaults { | extension UserDefaults { | ||||
private enum DefaultsError: Error { | |||||
case couldNotMigrateStandardDefaults | |||||
var description: String { | |||||
switch self { | |||||
case .couldNotMigrateStandardDefaults: | |||||
return "Could not migrate user defaults to group container." | |||||
} | |||||
} | |||||
} | |||||
private static let appGroupName: String = "group.com.abunchtell.writefreely" | private static let appGroupName: String = "group.com.abunchtell.writefreely" | ||||
private static let didMigrateDefaultsToAppGroup: String = "didMigrateDefaultsToAppGroup" | private static let didMigrateDefaultsToAppGroup: String = "didMigrateDefaultsToAppGroup" | ||||
private static let didRemoveStandardDefaults: String = "didRemoveStandardDefaults" | private static let didRemoveStandardDefaults: String = "didRemoveStandardDefaults" | ||||
@@ -61,7 +50,7 @@ extension UserDefaults { | |||||
groupDefaults.set(true, forKey: UserDefaults.didMigrateDefaultsToAppGroup) | groupDefaults.set(true, forKey: UserDefaults.didMigrateDefaultsToAppGroup) | ||||
return groupDefaults | return groupDefaults | ||||
} else { | } else { | ||||
throw DefaultsError.couldNotMigrateStandardDefaults | |||||
throw UserDefaultsError.couldNotMigrateStandardDefaults | |||||
} | } | ||||
} | } | ||||
@@ -4,7 +4,7 @@ import WriteFreely | |||||
extension WriteFreelyModel { | extension WriteFreelyModel { | ||||
func login(to server: URL, as username: String, password: String) { | func login(to server: URL, as username: String, password: String) { | ||||
if !hasNetworkConnection { | if !hasNetworkConnection { | ||||
isPresentingNetworkErrorAlert = true | |||||
self.currentError = NetworkError.noConnectionError | |||||
return | return | ||||
} | } | ||||
let secureProtocolPrefix = "https://" | let secureProtocolPrefix = "https://" | ||||
@@ -30,7 +30,7 @@ extension WriteFreelyModel { | |||||
func logout() { | func logout() { | ||||
if !hasNetworkConnection { | if !hasNetworkConnection { | ||||
DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } | |||||
self.currentError = NetworkError.noConnectionError | |||||
return | return | ||||
} | } | ||||
guard let loggedInClient = client else { | guard let loggedInClient = client else { | ||||
@@ -38,7 +38,7 @@ extension WriteFreelyModel { | |||||
try purgeTokenFromKeychain(username: account.username, server: account.server) | try purgeTokenFromKeychain(username: account.username, server: account.server) | ||||
account.logout() | account.logout() | ||||
} catch { | } catch { | ||||
fatalError("Failed to log out persisted state") | |||||
self.currentError = KeychainError.couldNotPurgeAccessToken | |||||
} | } | ||||
return | return | ||||
} | } | ||||
@@ -47,10 +47,13 @@ extension WriteFreelyModel { | |||||
func fetchUserCollections() { | func fetchUserCollections() { | ||||
if !hasNetworkConnection { | if !hasNetworkConnection { | ||||
DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } | |||||
self.currentError = NetworkError.noConnectionError | |||||
return | |||||
} | |||||
guard let loggedInClient = client else { | |||||
self.currentError = AppError.couldNotGetLoggedInClient | |||||
return | return | ||||
} | } | ||||
guard let loggedInClient = client else { return } | |||||
// We're starting the network request. | // We're starting the network request. | ||||
DispatchQueue.main.async { | DispatchQueue.main.async { | ||||
self.isProcessingRequest = true | self.isProcessingRequest = true | ||||
@@ -60,10 +63,13 @@ extension WriteFreelyModel { | |||||
func fetchUserPosts() { | func fetchUserPosts() { | ||||
if !hasNetworkConnection { | if !hasNetworkConnection { | ||||
DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } | |||||
self.currentError = NetworkError.noConnectionError | |||||
return | |||||
} | |||||
guard let loggedInClient = client else { | |||||
self.currentError = AppError.couldNotGetLoggedInClient | |||||
return | return | ||||
} | } | ||||
guard let loggedInClient = client else { return } | |||||
// We're starting the network request. | // We're starting the network request. | ||||
DispatchQueue.main.async { | DispatchQueue.main.async { | ||||
self.isProcessingRequest = true | self.isProcessingRequest = true | ||||
@@ -75,10 +81,13 @@ extension WriteFreelyModel { | |||||
postToUpdate = nil | postToUpdate = nil | ||||
if !hasNetworkConnection { | if !hasNetworkConnection { | ||||
DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } | |||||
self.currentError = NetworkError.noConnectionError | |||||
return | |||||
} | |||||
guard let loggedInClient = client else { | |||||
self.currentError = AppError.couldNotGetLoggedInClient | |||||
return | return | ||||
} | } | ||||
guard let loggedInClient = client else { return } | |||||
// We're starting the network request. | // We're starting the network request. | ||||
DispatchQueue.main.async { | DispatchQueue.main.async { | ||||
self.isProcessingRequest = true | self.isProcessingRequest = true | ||||
@@ -120,11 +129,17 @@ extension WriteFreelyModel { | |||||
func updateFromServer(post: WFAPost) { | func updateFromServer(post: WFAPost) { | ||||
if !hasNetworkConnection { | if !hasNetworkConnection { | ||||
DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } | |||||
self.currentError = NetworkError.noConnectionError | |||||
return | |||||
} | |||||
guard let loggedInClient = client else { | |||||
self.currentError = AppError.couldNotGetLoggedInClient | |||||
return | |||||
} | |||||
guard let postId = post.postId else { | |||||
self.currentError = AppError.couldNotGetPostId | |||||
return | return | ||||
} | } | ||||
guard let loggedInClient = client else { return } | |||||
guard let postId = post.postId else { return } | |||||
// We're starting the network request. | // We're starting the network request. | ||||
DispatchQueue.main.async { | DispatchQueue.main.async { | ||||
self.selectedPost = post | self.selectedPost = post | ||||
@@ -135,11 +150,17 @@ extension WriteFreelyModel { | |||||
func move(post: WFAPost, from oldCollection: WFACollection?, to newCollection: WFACollection?) { | func move(post: WFAPost, from oldCollection: WFACollection?, to newCollection: WFACollection?) { | ||||
if !hasNetworkConnection { | if !hasNetworkConnection { | ||||
DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } | |||||
self.currentError = NetworkError.noConnectionError | |||||
return | |||||
} | |||||
guard let loggedInClient = client else { | |||||
self.currentError = AppError.couldNotGetLoggedInClient | |||||
return | |||||
} | |||||
guard let postId = post.postId else { | |||||
self.currentError = AppError.couldNotGetPostId | |||||
return | return | ||||
} | } | ||||
guard let loggedInClient = client, | |||||
let postId = post.postId else { return } | |||||
// We're starting the network request. | // We're starting the network request. | ||||
DispatchQueue.main.async { | DispatchQueue.main.async { | ||||
self.isProcessingRequest = true | self.isProcessingRequest = true | ||||
@@ -16,33 +16,18 @@ extension WriteFreelyModel { | |||||
self.account.login(user) | self.account.login(user) | ||||
} | } | ||||
} catch { | } catch { | ||||
DispatchQueue.main.async { | |||||
self.loginErrorMessage = "There was a problem storing your access token to the Keychain." | |||||
self.isPresentingLoginErrorAlert = true | |||||
} | |||||
self.currentError = KeychainError.couldNotStoreAccessToken | |||||
} | } | ||||
} catch WFError.notFound { | } catch WFError.notFound { | ||||
DispatchQueue.main.async { | |||||
self.loginErrorMessage = AccountError.usernameNotFound.localizedDescription | |||||
self.isPresentingLoginErrorAlert = true | |||||
} | |||||
self.currentError = AccountError.usernameNotFound | |||||
} catch WFError.unauthorized { | } catch WFError.unauthorized { | ||||
DispatchQueue.main.async { | |||||
self.loginErrorMessage = AccountError.invalidPassword.localizedDescription | |||||
self.isPresentingLoginErrorAlert = true | |||||
} | |||||
self.currentError = AccountError.invalidPassword | |||||
} catch { | } catch { | ||||
if (error as NSError).domain == NSURLErrorDomain, | if (error as NSError).domain == NSURLErrorDomain, | ||||
(error as NSError).code == -1003 { | (error as NSError).code == -1003 { | ||||
DispatchQueue.main.async { | |||||
self.loginErrorMessage = AccountError.serverNotFound.localizedDescription | |||||
self.isPresentingLoginErrorAlert = true | |||||
} | |||||
self.currentError = AccountError.serverNotFound | |||||
} else { | } else { | ||||
DispatchQueue.main.async { | |||||
self.loginErrorMessage = error.localizedDescription | |||||
self.isPresentingLoginErrorAlert = true | |||||
} | |||||
self.currentError = error | |||||
} | } | ||||
} | } | ||||
} | } | ||||
@@ -55,11 +40,15 @@ extension WriteFreelyModel { | |||||
client = nil | client = nil | ||||
DispatchQueue.main.async { | DispatchQueue.main.async { | ||||
self.account.logout() | self.account.logout() | ||||
LocalStorageManager.standard.purgeUserCollections() | |||||
self.posts.purgePublishedPosts() | |||||
do { | |||||
try LocalStorageManager.standard.purgeUserCollections() | |||||
try self.posts.purgePublishedPosts() | |||||
} catch { | |||||
self.currentError = error | |||||
} | |||||
} | } | ||||
} catch { | } catch { | ||||
print("Something went wrong purging the token from the Keychain.") | |||||
self.currentError = KeychainError.couldNotPurgeAccessToken | |||||
} | } | ||||
} catch WFError.notFound { | } catch WFError.notFound { | ||||
// The user token is invalid or doesn't exist, so it's been invalidated by the server. Proceed with | // The user token is invalid or doesn't exist, so it's been invalidated by the server. Proceed with | ||||
@@ -70,11 +59,15 @@ extension WriteFreelyModel { | |||||
client = nil | client = nil | ||||
DispatchQueue.main.async { | DispatchQueue.main.async { | ||||
self.account.logout() | self.account.logout() | ||||
LocalStorageManager.standard.purgeUserCollections() | |||||
self.posts.purgePublishedPosts() | |||||
do { | |||||
try LocalStorageManager.standard.purgeUserCollections() | |||||
try self.posts.purgePublishedPosts() | |||||
} catch { | |||||
self.currentError = error | |||||
} | |||||
} | } | ||||
} catch { | } catch { | ||||
print("Something went wrong purging the token from the Keychain.") | |||||
self.currentError = KeychainError.couldNotPurgeAccessToken | |||||
} | } | ||||
} catch { | } catch { | ||||
// We get a 'cannot parse response' (similar to what we were seeing in the Swift package) NSURLError here, | // We get a 'cannot parse response' (similar to what we were seeing in the Swift package) NSURLError here, | ||||
@@ -113,13 +106,10 @@ extension WriteFreelyModel { | |||||
LocalStorageManager.standard.saveContext() | LocalStorageManager.standard.saveContext() | ||||
} | } | ||||
} catch WFError.unauthorized { | } catch WFError.unauthorized { | ||||
DispatchQueue.main.async { | |||||
self.loginErrorMessage = "Something went wrong, please try logging in again." | |||||
self.isPresentingLoginErrorAlert = true | |||||
} | |||||
self.currentError = AccountError.genericAuthError | |||||
self.logout() | self.logout() | ||||
} catch { | } catch { | ||||
print(error) | |||||
self.currentError = AppError.genericError(error.localizedDescription) | |||||
} | } | ||||
} | } | ||||
@@ -141,7 +131,11 @@ extension WriteFreelyModel { | |||||
if let fetchedPostUpdatedDate = fetchedPost.updatedDate, | if let fetchedPostUpdatedDate = fetchedPost.updatedDate, | ||||
let localPostUpdatedDate = managedPost.updatedDate { | let localPostUpdatedDate = managedPost.updatedDate { | ||||
managedPost.hasNewerRemoteCopy = fetchedPostUpdatedDate > localPostUpdatedDate | managedPost.hasNewerRemoteCopy = fetchedPostUpdatedDate > localPostUpdatedDate | ||||
} else { print("Error: could not determine which copy of post is newer") } | |||||
} else { | |||||
self.currentError = AppError.genericError( | |||||
"Error updating post: could not determine which copy of post is newer." | |||||
) | |||||
} | |||||
postsToDelete.removeAll(where: { $0.postId == fetchedPost.postId }) | postsToDelete.removeAll(where: { $0.postId == fetchedPost.postId }) | ||||
} | } | ||||
} else { | } else { | ||||
@@ -158,16 +152,13 @@ extension WriteFreelyModel { | |||||
LocalStorageManager.standard.saveContext() | LocalStorageManager.standard.saveContext() | ||||
} | } | ||||
} catch { | } catch { | ||||
print(error) | |||||
self.currentError = AppError.genericError(error.localizedDescription) | |||||
} | } | ||||
} catch WFError.unauthorized { | } catch WFError.unauthorized { | ||||
DispatchQueue.main.async { | |||||
self.loginErrorMessage = "Something went wrong, please try logging in again." | |||||
self.isPresentingLoginErrorAlert = true | |||||
} | |||||
self.currentError = AccountError.genericAuthError | |||||
self.logout() | self.logout() | ||||
} catch { | } catch { | ||||
print("Error: Failed to fetch cached posts") | |||||
self.currentError = LocalStoreError.couldNotFetchPosts("cached") | |||||
} | } | ||||
} | } | ||||
@@ -211,11 +202,11 @@ extension WriteFreelyModel { | |||||
LocalStorageManager.standard.saveContext() | LocalStorageManager.standard.saveContext() | ||||
} | } | ||||
} catch { | } catch { | ||||
print("Error: Failed to fetch cached posts") | |||||
self.currentError = LocalStoreError.couldNotFetchPosts("cached") | |||||
} | } | ||||
} | } | ||||
} catch { | } catch { | ||||
print(error) | |||||
self.currentError = AppError.genericError(error.localizedDescription) | |||||
} | } | ||||
} | } | ||||
@@ -237,7 +228,7 @@ extension WriteFreelyModel { | |||||
LocalStorageManager.standard.saveContext() | LocalStorageManager.standard.saveContext() | ||||
} | } | ||||
} catch { | } catch { | ||||
print(error) | |||||
self.currentError = AppError.genericError(error.localizedDescription) | |||||
} | } | ||||
} | } | ||||
@@ -259,7 +250,7 @@ extension WriteFreelyModel { | |||||
DispatchQueue.main.async { | DispatchQueue.main.async { | ||||
LocalStorageManager.standard.container.viewContext.rollback() | LocalStorageManager.standard.container.viewContext.rollback() | ||||
} | } | ||||
print(error) | |||||
self.currentError = AppError.genericError(error.localizedDescription) | |||||
} | } | ||||
} | } | ||||
@@ -2,12 +2,6 @@ import Foundation | |||||
extension WriteFreelyModel { | extension WriteFreelyModel { | ||||
enum WFKeychainError: Error { | |||||
case saveToKeychainFailed | |||||
case purgeFromKeychainFailed | |||||
case fetchFromKeychainFailed | |||||
} | |||||
func saveTokenToKeychain(_ token: String, username: String?, server: String) throws { | func saveTokenToKeychain(_ token: String, username: String?, server: String) throws { | ||||
let query: [String: Any] = [ | let query: [String: Any] = [ | ||||
kSecClass as String: kSecClassGenericPassword, | kSecClass as String: kSecClassGenericPassword, | ||||
@@ -17,7 +11,7 @@ extension WriteFreelyModel { | |||||
] | ] | ||||
let status = SecItemAdd(query as CFDictionary, nil) | let status = SecItemAdd(query as CFDictionary, nil) | ||||
guard status == errSecDuplicateItem || status == errSecSuccess else { | guard status == errSecDuplicateItem || status == errSecSuccess else { | ||||
throw WFKeychainError.saveToKeychainFailed | |||||
throw KeychainError.couldNotStoreAccessToken | |||||
} | } | ||||
} | } | ||||
@@ -29,7 +23,7 @@ extension WriteFreelyModel { | |||||
] | ] | ||||
let status = SecItemDelete(query as CFDictionary) | let status = SecItemDelete(query as CFDictionary) | ||||
guard status == errSecSuccess || status == errSecItemNotFound else { | guard status == errSecSuccess || status == errSecItemNotFound else { | ||||
throw WFKeychainError.purgeFromKeychainFailed | |||||
throw KeychainError.couldNotPurgeAccessToken | |||||
} | } | ||||
} | } | ||||
@@ -45,15 +39,15 @@ extension WriteFreelyModel { | |||||
var secItem: CFTypeRef? | var secItem: CFTypeRef? | ||||
let status = SecItemCopyMatching(query as CFDictionary, &secItem) | let status = SecItemCopyMatching(query as CFDictionary, &secItem) | ||||
guard status != errSecItemNotFound else { | guard status != errSecItemNotFound else { | ||||
return nil | |||||
throw KeychainError.couldNotFetchAccessToken | |||||
} | } | ||||
guard status == errSecSuccess else { | guard status == errSecSuccess else { | ||||
throw WFKeychainError.fetchFromKeychainFailed | |||||
throw KeychainError.couldNotFetchAccessToken | |||||
} | } | ||||
guard let existingSecItem = secItem as? [String: Any], | guard let existingSecItem = secItem as? [String: Any], | ||||
let tokenData = existingSecItem[kSecValueData as String] as? Data, | let tokenData = existingSecItem[kSecValueData as String] as? Data, | ||||
let token = String(data: tokenData, encoding: .utf8) else { | let token = String(data: tokenData, encoding: .utf8) else { | ||||
return nil | |||||
throw KeychainError.couldNotFetchAccessToken | |||||
} | } | ||||
return token | return token | ||||
} | } | ||||
@@ -23,19 +23,19 @@ final class LocalStorageManager { | |||||
do { | do { | ||||
try container.viewContext.save() | try container.viewContext.save() | ||||
} catch { | } catch { | ||||
print("Error saving context: \(error)") | |||||
fatalError(LocalStoreError.couldNotSaveContext.localizedDescription) | |||||
} | } | ||||
} | } | ||||
} | } | ||||
func purgeUserCollections() { | |||||
func purgeUserCollections() throws { | |||||
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "WFACollection") | let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "WFACollection") | ||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) | let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) | ||||
do { | do { | ||||
try container.viewContext.executeAndMergeChanges(using: deleteRequest) | try container.viewContext.executeAndMergeChanges(using: deleteRequest) | ||||
} catch { | } catch { | ||||
print("Error: Failed to purge cached collections.") | |||||
throw LocalStoreError.couldNotPurgeCollections | |||||
} | } | ||||
} | } | ||||
@@ -61,7 +61,7 @@ private extension LocalStorageManager { | |||||
container.loadPersistentStores { _, error in | container.loadPersistentStores { _, error in | ||||
if let error = error { | if let error = error { | ||||
fatalError("Core Data store failed to load with error: \(error)") | |||||
fatalError(LocalStoreError.couldNotLoadStore(error.localizedDescription).localizedDescription) | |||||
} | } | ||||
} | } | ||||
migrateStore(for: container) | migrateStore(for: container) | ||||
@@ -88,14 +88,16 @@ private extension LocalStorageManager { | |||||
options: nil, | options: nil, | ||||
withType: NSSQLiteStoreType) | withType: NSSQLiteStoreType) | ||||
} catch { | } catch { | ||||
fatalError("Something went wrong migrating the store: \(error)") | |||||
fatalError(LocalStoreError.couldNotMigrateStore(error.localizedDescription).localizedDescription) | |||||
} | } | ||||
// Attempt to delete the old store. | // Attempt to delete the old store. | ||||
do { | do { | ||||
try FileManager.default.removeItem(at: oldStoreURL) | try FileManager.default.removeItem(at: oldStoreURL) | ||||
} catch { | } catch { | ||||
fatalError("Something went wrong while deleting the old store: \(error)") | |||||
fatalError( | |||||
LocalStoreError.couldNotDeleteStoreAfterMigration(error.localizedDescription).localizedDescription | |||||
) | |||||
} | } | ||||
} | } | ||||
@@ -6,10 +6,34 @@ import Network | |||||
// MARK: - WriteFreelyModel | // MARK: - WriteFreelyModel | ||||
final class WriteFreelyModel: ObservableObject { | final class WriteFreelyModel: ObservableObject { | ||||
// MARK: - Models | |||||
@Published var account = AccountModel() | @Published var account = AccountModel() | ||||
@Published var preferences = PreferencesModel() | @Published var preferences = PreferencesModel() | ||||
@Published var posts = PostListModel() | @Published var posts = PostListModel() | ||||
@Published var editor = PostEditorModel() | @Published var editor = PostEditorModel() | ||||
// MARK: - Error handling | |||||
@Published var hasError: Bool = false | |||||
var currentError: Error? { | |||||
didSet { | |||||
#if DEBUG | |||||
print("⚠️ currentError -> didSet \(currentError?.localizedDescription ?? "nil")") | |||||
print(" > hasError was: \(self.hasError)") | |||||
#endif | |||||
DispatchQueue.main.async { | |||||
#if DEBUG | |||||
print(" > self.currentError != nil: \(self.currentError != nil)") | |||||
#endif | |||||
self.hasError = self.currentError != nil | |||||
#if DEBUG | |||||
print(" > hasError is now: \(self.hasError)") | |||||
#endif | |||||
} | |||||
} | |||||
} | |||||
// MARK: - State | |||||
@Published var isLoggingIn: Bool = false | @Published var isLoggingIn: Bool = false | ||||
@Published var isProcessingRequest: Bool = false | @Published var isProcessingRequest: Bool = false | ||||
@Published var hasNetworkConnection: Bool = true | @Published var hasNetworkConnection: Bool = true | ||||
@@ -17,17 +41,13 @@ final class WriteFreelyModel: ObservableObject { | |||||
@Published var selectedCollection: WFACollection? | @Published var selectedCollection: WFACollection? | ||||
@Published var showAllPosts: Bool = true | @Published var showAllPosts: Bool = true | ||||
@Published var isPresentingDeleteAlert: Bool = false | @Published var isPresentingDeleteAlert: Bool = false | ||||
@Published var isPresentingLoginErrorAlert: Bool = false | |||||
@Published var isPresentingNetworkErrorAlert: Bool = false | |||||
@Published var postToDelete: WFAPost? | @Published var postToDelete: WFAPost? | ||||
#if os(iOS) | |||||
#if os(iOS) | |||||
@Published var isPresentingSettingsView: Bool = false | @Published var isPresentingSettingsView: Bool = false | ||||
#endif | |||||
#endif | |||||
static var shared = WriteFreelyModel() | static var shared = WriteFreelyModel() | ||||
var loginErrorMessage: String? | |||||
// swiftlint:disable line_length | // swiftlint:disable line_length | ||||
let helpURL = URL(string: "https://discuss.write.as/c/help/5")! | let helpURL = URL(string: "https://discuss.write.as/c/help/5")! | ||||
let howToURL = URL(string: "https://discuss.write.as/t/using-the-writefreely-ios-app/1946")! | let howToURL = URL(string: "https://discuss.write.as/t/using-the-writefreely-ios-app/1946")! | ||||
@@ -48,7 +68,7 @@ final class WriteFreelyModel: ObservableObject { | |||||
self.account.restoreState() | self.account.restoreState() | ||||
if self.account.isLoggedIn { | if self.account.isLoggedIn { | ||||
guard let serverURL = URL(string: self.account.server) else { | guard let serverURL = URL(string: self.account.server) else { | ||||
print("Server URL not found") | |||||
self.currentError = AccountError.invalidServerURL | |||||
return | return | ||||
} | } | ||||
do { | do { | ||||
@@ -56,8 +76,7 @@ final class WriteFreelyModel: ObservableObject { | |||||
username: self.account.username, | username: self.account.username, | ||||
server: self.account.server | server: self.account.server | ||||
) else { | ) else { | ||||
self.loginErrorMessage = AccountError.couldNotFetchTokenFromKeychain.localizedDescription | |||||
self.isPresentingLoginErrorAlert = true | |||||
self.currentError = KeychainError.couldNotFetchAccessToken | |||||
return | return | ||||
} | } | ||||
@@ -67,8 +86,8 @@ final class WriteFreelyModel: ObservableObject { | |||||
self.fetchUserCollections() | self.fetchUserCollections() | ||||
self.fetchUserPosts() | self.fetchUserPosts() | ||||
} catch { | } catch { | ||||
self.loginErrorMessage = AccountError.couldNotFetchTokenFromKeychain.localizedDescription | |||||
self.isPresentingLoginErrorAlert = true | |||||
self.currentError = KeychainError.couldNotFetchAccessToken | |||||
return | |||||
} | } | ||||
} | } | ||||
} | } | ||||
@@ -2,11 +2,13 @@ import SwiftUI | |||||
struct ContentView: View { | struct ContentView: View { | ||||
@EnvironmentObject var model: WriteFreelyModel | @EnvironmentObject var model: WriteFreelyModel | ||||
@EnvironmentObject var errorHandling: ErrorHandling | |||||
var body: some View { | var body: some View { | ||||
NavigationView { | NavigationView { | ||||
#if os(macOS) | #if os(macOS) | ||||
CollectionListView() | CollectionListView() | ||||
.withErrorHandling() | |||||
.toolbar { | .toolbar { | ||||
Button( | Button( | ||||
action: { | action: { | ||||
@@ -36,11 +38,13 @@ struct ContentView: View { | |||||
} | } | ||||
#else | #else | ||||
CollectionListView() | CollectionListView() | ||||
.withErrorHandling() | |||||
#endif | #endif | ||||
#if os(macOS) | #if os(macOS) | ||||
ZStack { | ZStack { | ||||
PostListView(selectedCollection: model.selectedCollection, showAllPosts: model.showAllPosts) | PostListView(selectedCollection: model.selectedCollection, showAllPosts: model.showAllPosts) | ||||
.withErrorHandling() | |||||
if model.isProcessingRequest { | if model.isProcessingRequest { | ||||
ZStack { | ZStack { | ||||
Color(NSColor.controlBackgroundColor).opacity(0.75) | Color(NSColor.controlBackgroundColor).opacity(0.75) | ||||
@@ -50,12 +54,23 @@ struct ContentView: View { | |||||
} | } | ||||
#else | #else | ||||
PostListView(selectedCollection: model.selectedCollection, showAllPosts: model.showAllPosts) | PostListView(selectedCollection: model.selectedCollection, showAllPosts: model.showAllPosts) | ||||
.withErrorHandling() | |||||
#endif | #endif | ||||
Text("Select a post, or create a new local draft.") | Text("Select a post, or create a new local draft.") | ||||
.foregroundColor(.secondary) | .foregroundColor(.secondary) | ||||
} | } | ||||
.environmentObject(model) | .environmentObject(model) | ||||
.onChange(of: model.hasError) { value in | |||||
if value { | |||||
if let error = model.currentError { | |||||
self.errorHandling.handle(error: error) | |||||
} else { | |||||
self.errorHandling.handle(error: AppError.genericError()) | |||||
} | |||||
model.hasError = false | |||||
} | |||||
} | |||||
} | } | ||||
} | } | ||||
@@ -19,7 +19,8 @@ class CollectionListModel: NSObject, ObservableObject { | |||||
try collectionsController.performFetch() | try collectionsController.performFetch() | ||||
list = collectionsController.fetchedObjects ?? [] | list = collectionsController.fetchedObjects ?? [] | ||||
} catch { | } catch { | ||||
print("Failed to fetch collections!") | |||||
// FIXME: Errors cannot be thrown out of the CollectionListView property initializer | |||||
fatalError(LocalStoreError.couldNotFetchCollections.localizedDescription) | |||||
} | } | ||||
} | } | ||||
} | } | ||||
@@ -2,6 +2,7 @@ import SwiftUI | |||||
struct CollectionListView: View { | struct CollectionListView: View { | ||||
@EnvironmentObject var model: WriteFreelyModel | @EnvironmentObject var model: WriteFreelyModel | ||||
@EnvironmentObject var errorHandling: ErrorHandling | |||||
@ObservedObject var collections = CollectionListModel( | @ObservedObject var collections = CollectionListModel( | ||||
managedObjectContext: LocalStorageManager.standard.container.viewContext | managedObjectContext: LocalStorageManager.standard.container.viewContext | ||||
) | ) | ||||
@@ -40,6 +41,16 @@ struct CollectionListView: View { | |||||
self.model.editor.showAllPostsFlag = model.showAllPosts | self.model.editor.showAllPostsFlag = model.showAllPosts | ||||
} | } | ||||
} | } | ||||
.onChange(of: model.hasError) { value in | |||||
if value { | |||||
if let error = model.currentError { | |||||
self.errorHandling.handle(error: error) | |||||
} else { | |||||
self.errorHandling.handle(error: AppError.genericError()) | |||||
} | |||||
model.hasError = false | |||||
} | |||||
} | |||||
} | } | ||||
} | } | ||||
@@ -9,7 +9,7 @@ class PostListModel: ObservableObject { | |||||
} | } | ||||
} | } | ||||
func purgePublishedPosts() { | |||||
func purgePublishedPosts() throws { | |||||
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "WFAPost") | let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "WFAPost") | ||||
fetchRequest.predicate = NSPredicate(format: "status != %i", 0) | fetchRequest.predicate = NSPredicate(format: "status != %i", 0) | ||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) | let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) | ||||
@@ -17,7 +17,7 @@ class PostListModel: ObservableObject { | |||||
do { | do { | ||||
try LocalStorageManager.standard.container.viewContext.executeAndMergeChanges(using: deleteRequest) | try LocalStorageManager.standard.container.viewContext.executeAndMergeChanges(using: deleteRequest) | ||||
} catch { | } catch { | ||||
print("Error: Failed to purge cached posts.") | |||||
throw LocalStoreError.couldNotPurgePosts("cached") | |||||
} | } | ||||
} | } | ||||
@@ -3,6 +3,7 @@ import Combine | |||||
struct PostListView: View { | struct PostListView: View { | ||||
@EnvironmentObject var model: WriteFreelyModel | @EnvironmentObject var model: WriteFreelyModel | ||||
@EnvironmentObject var errorHandling: ErrorHandling | |||||
@Environment(\.managedObjectContext) var managedObjectContext | @Environment(\.managedObjectContext) var managedObjectContext | ||||
@State private var postCount: Int = 0 | @State private var postCount: Int = 0 | ||||
@@ -86,17 +87,6 @@ struct PostListView: View { | |||||
Spacer() | Spacer() | ||||
Text(postCount == 1 ? "\(postCount) post" : "\(postCount) posts") | Text(postCount == 1 ? "\(postCount) post" : "\(postCount) posts") | ||||
.foregroundColor(.secondary) | .foregroundColor(.secondary) | ||||
.alert(isPresented: $model.isPresentingNetworkErrorAlert, content: { | |||||
Alert( | |||||
title: Text("Connection Error"), | |||||
message: Text(""" | |||||
There is no internet connection at the moment. Please reconnect or try again later. | |||||
"""), | |||||
dismissButton: .default(Text("OK"), action: { | |||||
model.isPresentingNetworkErrorAlert = false | |||||
}) | |||||
) | |||||
}) | |||||
Spacer() | Spacer() | ||||
if model.isProcessingRequest { | if model.isProcessingRequest { | ||||
ProgressView() | ProgressView() | ||||
@@ -138,6 +128,16 @@ struct PostListView: View { | |||||
model.selectedCollection = selectedCollection | model.selectedCollection = selectedCollection | ||||
model.showAllPosts = showAllPosts | model.showAllPosts = showAllPosts | ||||
} | } | ||||
.onChange(of: model.hasError) { value in | |||||
if value { | |||||
if let error = model.currentError { | |||||
self.errorHandling.handle(error: error) | |||||
} else { | |||||
self.errorHandling.handle(error: AppError.genericError()) | |||||
} | |||||
model.hasError = false | |||||
} | |||||
} | |||||
#else | #else | ||||
PostListFilteredView( | PostListFilteredView( | ||||
collection: selectedCollection, | collection: selectedCollection, | ||||
@@ -148,18 +148,6 @@ struct PostListView: View { | |||||
ToolbarItemGroup(placement: .primaryAction) { | ToolbarItemGroup(placement: .primaryAction) { | ||||
if model.selectedPost != nil { | if model.selectedPost != nil { | ||||
ActivePostToolbarView(activePost: model.selectedPost!) | ActivePostToolbarView(activePost: model.selectedPost!) | ||||
.alert(isPresented: $model.isPresentingNetworkErrorAlert, content: { | |||||
Alert( | |||||
title: Text("Connection Error"), | |||||
message: Text(""" | |||||
There is no internet connection at the moment. \ | |||||
Please reconnect or try again later. | |||||
"""), | |||||
dismissButton: .default(Text("OK"), action: { | |||||
model.isPresentingNetworkErrorAlert = false | |||||
}) | |||||
) | |||||
}) | |||||
} | } | ||||
} | } | ||||
} | } | ||||
@@ -172,6 +160,16 @@ struct PostListView: View { | |||||
model.selectedCollection = selectedCollection | model.selectedCollection = selectedCollection | ||||
model.showAllPosts = showAllPosts | model.showAllPosts = showAllPosts | ||||
} | } | ||||
.onChange(of: model.hasError) { value in | |||||
if value { | |||||
if let error = model.currentError { | |||||
self.errorHandling.handle(error: error) | |||||
} else { | |||||
self.errorHandling.handle(error: AppError.genericError()) | |||||
} | |||||
model.hasError = false | |||||
} | |||||
} | |||||
#endif | #endif | ||||
} | } | ||||
} | } | ||||
@@ -48,6 +48,7 @@ struct WriteFreely_MultiPlatformApp: App { | |||||
} | } | ||||
} | } | ||||
}) | }) | ||||
.withErrorHandling() | |||||
.environmentObject(model) | .environmentObject(model) | ||||
.environment(\.managedObjectContext, LocalStorageManager.standard.container.viewContext) | .environment(\.managedObjectContext, LocalStorageManager.standard.container.viewContext) | ||||
// .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. | // .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. | ||||
@@ -24,6 +24,12 @@ | |||||
171BFDFB24D4AF8300888236 /* CollectionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171BFDF924D4AF8300888236 /* CollectionListView.swift */; }; | 171BFDFB24D4AF8300888236 /* CollectionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171BFDF924D4AF8300888236 /* CollectionListView.swift */; }; | ||||
171DC677272C7D0B002B9B8A /* UserDefaults+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171DC676272C7D0B002B9B8A /* UserDefaults+Extensions.swift */; }; | 171DC677272C7D0B002B9B8A /* UserDefaults+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171DC676272C7D0B002B9B8A /* UserDefaults+Extensions.swift */; }; | ||||
171DC678272C7D0B002B9B8A /* UserDefaults+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171DC676272C7D0B002B9B8A /* UserDefaults+Extensions.swift */; }; | 171DC678272C7D0B002B9B8A /* UserDefaults+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171DC676272C7D0B002B9B8A /* UserDefaults+Extensions.swift */; }; | ||||
1727526628099802003D0A6A /* ErrorConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1727526528099802003D0A6A /* ErrorConstants.swift */; }; | |||||
1727526728099802003D0A6A /* ErrorConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1727526528099802003D0A6A /* ErrorConstants.swift */; }; | |||||
1727526828099802003D0A6A /* ErrorConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1727526528099802003D0A6A /* ErrorConstants.swift */; }; | |||||
1727526A2809991A003D0A6A /* ErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172752692809991A003D0A6A /* ErrorHandling.swift */; }; | |||||
1727526B2809991A003D0A6A /* ErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172752692809991A003D0A6A /* ErrorHandling.swift */; }; | |||||
1727526C2809991A003D0A6A /* ErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172752692809991A003D0A6A /* ErrorHandling.swift */; }; | |||||
172C492E2593981900E20ADF /* MacUpdatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172C492D2593981900E20ADF /* MacUpdatesView.swift */; }; | 172C492E2593981900E20ADF /* MacUpdatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172C492D2593981900E20ADF /* MacUpdatesView.swift */; }; | ||||
172E10012735B83E00061372 /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 172E10002735B83E00061372 /* UniformTypeIdentifiers.framework */; platformFilter = maccatalyst; }; | 172E10012735B83E00061372 /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 172E10002735B83E00061372 /* UniformTypeIdentifiers.framework */; platformFilter = maccatalyst; }; | ||||
172E10042735B83E00061372 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 172E10032735B83E00061372 /* Media.xcassets */; }; | 172E10042735B83E00061372 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 172E10032735B83E00061372 /* Media.xcassets */; }; | ||||
@@ -181,6 +187,8 @@ | |||||
17120DB124E1E19C002B9F6C /* SettingsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = "<group>"; }; | 17120DB124E1E19C002B9F6C /* SettingsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = "<group>"; }; | ||||
171BFDF924D4AF8300888236 /* CollectionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionListView.swift; sourceTree = "<group>"; }; | 171BFDF924D4AF8300888236 /* CollectionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionListView.swift; sourceTree = "<group>"; }; | ||||
171DC676272C7D0B002B9B8A /* UserDefaults+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Extensions.swift"; sourceTree = "<group>"; }; | 171DC676272C7D0B002B9B8A /* UserDefaults+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Extensions.swift"; sourceTree = "<group>"; }; | ||||
1727526528099802003D0A6A /* ErrorConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorConstants.swift; sourceTree = "<group>"; }; | |||||
172752692809991A003D0A6A /* ErrorHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorHandling.swift; sourceTree = "<group>"; }; | |||||
172C492D2593981900E20ADF /* MacUpdatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacUpdatesView.swift; sourceTree = "<group>"; }; | 172C492D2593981900E20ADF /* MacUpdatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacUpdatesView.swift; sourceTree = "<group>"; }; | ||||
172E0FFF2735B83E00061372 /* ActionExtension-iOS.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "ActionExtension-iOS.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; | 172E0FFF2735B83E00061372 /* ActionExtension-iOS.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "ActionExtension-iOS.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||
172E10002735B83E00061372 /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; }; | 172E10002735B83E00061372 /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; }; | ||||
@@ -325,6 +333,15 @@ | |||||
path = Settings; | path = Settings; | ||||
sourceTree = "<group>"; | sourceTree = "<group>"; | ||||
}; | }; | ||||
17275264280997BF003D0A6A /* ErrorHandling */ = { | |||||
isa = PBXGroup; | |||||
children = ( | |||||
1727526528099802003D0A6A /* ErrorConstants.swift */, | |||||
172752692809991A003D0A6A /* ErrorHandling.swift */, | |||||
); | |||||
path = ErrorHandling; | |||||
sourceTree = "<group>"; | |||||
}; | |||||
172E10022735B83E00061372 /* ActionExtension-iOS */ = { | 172E10022735B83E00061372 /* ActionExtension-iOS */ = { | ||||
isa = PBXGroup; | isa = PBXGroup; | ||||
children = ( | children = ( | ||||
@@ -476,6 +493,7 @@ | |||||
17DF328324C87D3500BCE2E3 /* Assets.xcassets */, | 17DF328324C87D3500BCE2E3 /* Assets.xcassets */, | ||||
17DF32D024C8B75C00BCE2E3 /* Account */, | 17DF32D024C8B75C00BCE2E3 /* Account */, | ||||
1756AE7F24CB841200FD7257 /* Extensions */, | 1756AE7F24CB841200FD7257 /* Extensions */, | ||||
17275264280997BF003D0A6A /* ErrorHandling */, | |||||
1762DCB124EB07680019C4EB /* Models */, | 1762DCB124EB07680019C4EB /* Models */, | ||||
17DF32CC24C8B72300BCE2E3 /* Navigation */, | 17DF32CC24C8B72300BCE2E3 /* Navigation */, | ||||
1739B8D324EAFAB700DA7421 /* PostEditor */, | 1739B8D324EAFAB700DA7421 /* PostEditor */, | ||||
@@ -871,8 +889,10 @@ | |||||
172E10212735C64600061372 /* WFACollection+CoreDataProperties.swift in Sources */, | 172E10212735C64600061372 /* WFACollection+CoreDataProperties.swift in Sources */, | ||||
172E101C2735C57400061372 /* LocalStorageManager.swift in Sources */, | 172E101C2735C57400061372 /* LocalStorageManager.swift in Sources */, | ||||
172E10192735C3DB00061372 /* ContentView.swift in Sources */, | 172E10192735C3DB00061372 /* ContentView.swift in Sources */, | ||||
1727526828099802003D0A6A /* ErrorConstants.swift in Sources */, | |||||
172E10152735C2BD00061372 /* UIHostingView.swift in Sources */, | 172E10152735C2BD00061372 /* UIHostingView.swift in Sources */, | ||||
172E101F2735C64600061372 /* WFAPost+CoreDataClass.swift in Sources */, | 172E101F2735C64600061372 /* WFAPost+CoreDataClass.swift in Sources */, | ||||
1727526C2809991A003D0A6A /* ErrorHandling.swift in Sources */, | |||||
172E10232735C6FF00061372 /* NSManagedObjectContext+ExecuteAndMergeChanges.swift in Sources */, | 172E10232735C6FF00061372 /* NSManagedObjectContext+ExecuteAndMergeChanges.swift in Sources */, | ||||
172E101E2735C62F00061372 /* PostStatus.swift in Sources */, | 172E101E2735C62F00061372 /* PostStatus.swift in Sources */, | ||||
); | ); | ||||
@@ -887,6 +907,7 @@ | |||||
17B37C5625C8679800FE75E9 /* WriteFreelyModel+API.swift in Sources */, | 17B37C5625C8679800FE75E9 /* WriteFreelyModel+API.swift in Sources */, | ||||
17C42E622507D8E600072984 /* PostStatus.swift in Sources */, | 17C42E622507D8E600072984 /* PostStatus.swift in Sources */, | ||||
1756DBBA24FED45500207AB8 /* LocalStorageManager.swift in Sources */, | 1756DBBA24FED45500207AB8 /* LocalStorageManager.swift in Sources */, | ||||
1727526A2809991A003D0A6A /* ErrorHandling.swift in Sources */, | |||||
1756AE8124CB844500FD7257 /* View+Keyboard.swift in Sources */, | 1756AE8124CB844500FD7257 /* View+Keyboard.swift in Sources */, | ||||
17C42E652509237800072984 /* PostListFilteredView.swift in Sources */, | 17C42E652509237800072984 /* PostListFilteredView.swift in Sources */, | ||||
170DFA34251BBC44001D82A0 /* PostEditorModel.swift in Sources */, | 170DFA34251BBC44001D82A0 /* PostEditorModel.swift in Sources */, | ||||
@@ -912,6 +933,7 @@ | |||||
1756DC0124FEE18400207AB8 /* WFACollection+CoreDataClass.swift in Sources */, | 1756DC0124FEE18400207AB8 /* WFACollection+CoreDataClass.swift in Sources */, | ||||
17DF32AA24C87D3500BCE2E3 /* WriteFreely_MultiPlatformApp.swift in Sources */, | 17DF32AA24C87D3500BCE2E3 /* WriteFreely_MultiPlatformApp.swift in Sources */, | ||||
17120DA724E19D11002B9F6C /* SettingsView.swift in Sources */, | 17120DA724E19D11002B9F6C /* SettingsView.swift in Sources */, | ||||
1727526628099802003D0A6A /* ErrorConstants.swift in Sources */, | |||||
1756DC0324FEE18400207AB8 /* WFACollection+CoreDataProperties.swift in Sources */, | 1756DC0324FEE18400207AB8 /* WFACollection+CoreDataProperties.swift in Sources */, | ||||
17120DA224E1985C002B9F6C /* AccountModel.swift in Sources */, | 17120DA224E1985C002B9F6C /* AccountModel.swift in Sources */, | ||||
17120DA324E19A42002B9F6C /* PreferencesView.swift in Sources */, | 17120DA324E19A42002B9F6C /* PreferencesView.swift in Sources */, | ||||
@@ -937,6 +959,7 @@ | |||||
17120DAA24E1B2F5002B9F6C /* AccountLogoutView.swift in Sources */, | 17120DAA24E1B2F5002B9F6C /* AccountLogoutView.swift in Sources */, | ||||
17DF32D624C8CA3400BCE2E3 /* PostStatusBadgeView.swift in Sources */, | 17DF32D624C8CA3400BCE2E3 /* PostStatusBadgeView.swift in Sources */, | ||||
172C492E2593981900E20ADF /* MacUpdatesView.swift in Sources */, | 172C492E2593981900E20ADF /* MacUpdatesView.swift in Sources */, | ||||
1727526728099802003D0A6A /* ErrorConstants.swift in Sources */, | |||||
17479F152583D8E40072B7FB /* PostEditorSharingPicker.swift in Sources */, | 17479F152583D8E40072B7FB /* PostEditorSharingPicker.swift in Sources */, | ||||
17480CA6251272EE00EB7765 /* Bundle+AppVersion.swift in Sources */, | 17480CA6251272EE00EB7765 /* Bundle+AppVersion.swift in Sources */, | ||||
17C42E662509237800072984 /* PostListFilteredView.swift in Sources */, | 17C42E662509237800072984 /* PostListFilteredView.swift in Sources */, | ||||
@@ -944,6 +967,7 @@ | |||||
17D4926727947D780035BD7E /* MacUpdatesViewModel.swift in Sources */, | 17D4926727947D780035BD7E /* MacUpdatesViewModel.swift in Sources */, | ||||
17466626256C0D0600629997 /* MacEditorTextView.swift in Sources */, | 17466626256C0D0600629997 /* MacEditorTextView.swift in Sources */, | ||||
170A7EC226F5186A00F1CBD4 /* CollectionListModel.swift in Sources */, | 170A7EC226F5186A00F1CBD4 /* CollectionListModel.swift in Sources */, | ||||
1727526B2809991A003D0A6A /* ErrorHandling.swift in Sources */, | |||||
17E5DF8A2543610700DCDC9B /* PostTextEditingView.swift in Sources */, | 17E5DF8A2543610700DCDC9B /* PostTextEditingView.swift in Sources */, | ||||
17C42E71250AAFD500072984 /* NSManagedObjectContext+ExecuteAndMergeChanges.swift in Sources */, | 17C42E71250AAFD500072984 /* NSManagedObjectContext+ExecuteAndMergeChanges.swift in Sources */, | ||||
1756AE7B24CB65DF00FD7257 /* PostListView.swift in Sources */, | 1756AE7B24CB65DF00FD7257 /* PostListView.swift in Sources */, | ||||
@@ -1,24 +0,0 @@ | |||||
<?xml version="1.0" encoding="UTF-8"?> | |||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |||||
<plist version="1.0"> | |||||
<dict> | |||||
<key>SchemeUserState</key> | |||||
<dict> | |||||
<key>ActionExtension-iOS.xcscheme_^#shared#^_</key> | |||||
<dict> | |||||
<key>orderHint</key> | |||||
<integer>1</integer> | |||||
</dict> | |||||
<key>WriteFreely-MultiPlatform (iOS).xcscheme_^#shared#^_</key> | |||||
<dict> | |||||
<key>orderHint</key> | |||||
<integer>2</integer> | |||||
</dict> | |||||
<key>WriteFreely-MultiPlatform (macOS).xcscheme_^#shared#^_</key> | |||||
<dict> | |||||
<key>orderHint</key> | |||||
<integer>0</integer> | |||||
</dict> | |||||
</dict> | |||||
</dict> | |||||
</plist> |
@@ -9,6 +9,7 @@ struct SettingsView: View { | |||||
Form { | Form { | ||||
Section(header: Text("Login Details")) { | Section(header: Text("Login Details")) { | ||||
AccountView() | AccountView() | ||||
.withErrorHandling() | |||||
} | } | ||||
Section(header: Text("Appearance")) { | Section(header: Text("Appearance")) { | ||||
PreferencesView(preferences: model.preferences) | PreferencesView(preferences: model.preferences) | ||||