diff --git a/Sources/Helpers/ErrorHandling.swift b/Sources/Helpers/ErrorHandling.swift index 72d6e14..701bd9d 100644 --- a/Sources/Helpers/ErrorHandling.swift +++ b/Sources/Helpers/ErrorHandling.swift @@ -16,7 +16,7 @@ func withErrorHandling( before: (() -> Void)? = nil, success: (() -> Void)? = nil, failure: @escaping (String) -> Void, - invalidCreditentials: (() -> Void)? = nil, + invalidCredentials: (() -> Void)? = nil, anyways: (() -> Void)? = nil ) { if let before { @@ -39,7 +39,7 @@ func withErrorHandling( } catch let error as VOErrorResponse { DispatchQueue.main.async { if error.code == .invalidCredentials { - invalidCreditentials?() + invalidCredentials?() } else { failure(error.userMessage) } @@ -60,7 +60,7 @@ func withErrorHandling( before: (() -> Void)? = nil, success: (() -> Void)? = nil, failure: @escaping (String) -> Void, - invalidCreditentials: (() -> Void)? = nil, + invalidCredentials: (() -> Void)? = nil, anyways: (() -> Void)? = nil ) { Timer.scheduledTimer(withTimeInterval: delaySeconds, repeats: false) { _ in @@ -69,7 +69,7 @@ func withErrorHandling( before: before, success: success, failure: failure, - invalidCreditentials: invalidCreditentials, + invalidCredentials: invalidCredentials, anyways: anyways ) } diff --git a/Sources/Library/Views/Avatar.swift b/Sources/Library/Views/Avatar.swift index 26691bd..0fb5dae 100644 --- a/Sources/Library/Views/Avatar.swift +++ b/Sources/Library/Views/Avatar.swift @@ -11,11 +11,11 @@ import SwiftUI struct VOAvatar: View { - var name: String - var size: CGFloat - var url: URL? + private let name: String + private let size: CGFloat + private let url: URL? - public init(name: String, size: CGFloat, url: URL? = nil) { + init(name: String, size: CGFloat, url: URL? = nil) { self.name = name self.size = size self.url = url diff --git a/Sources/Library/Views/ButtonLabel.swift b/Sources/Library/Views/ButtonLabel.swift index 6b010e6..b1796ac 100644 --- a/Sources/Library/Views/ButtonLabel.swift +++ b/Sources/Library/Views/ButtonLabel.swift @@ -11,10 +11,10 @@ import SwiftUI struct VOButtonLabel: View { - var text: String - var systemImage: String? - var isLoading: Bool - var progressViewTint: Color + private let text: String + private let systemImage: String? + private let isLoading: Bool + private let progressViewTint: Color init(_ text: String, systemImage: String? = nil, isLoading: Bool = false, progressViewTint: Color = .primary) { self.text = text diff --git a/Sources/Library/Views/ColorBadge.swift b/Sources/Library/Views/ColorBadge.swift index 03ba6ea..fa0e6b5 100644 --- a/Sources/Library/Views/ColorBadge.swift +++ b/Sources/Library/Views/ColorBadge.swift @@ -11,9 +11,9 @@ import SwiftUI struct VOColorBadge: View { - var text: String - var color: Color - var style: Style + private let text: String + private let color: Color + private let style: Style init(_ text: String, color: Color, style: Style) { self.text = text diff --git a/Sources/Library/Views/ErrorMessage.swift b/Sources/Library/Views/ErrorMessage.swift index 10d2a48..cfd0b44 100644 --- a/Sources/Library/Views/ErrorMessage.swift +++ b/Sources/Library/Views/ErrorMessage.swift @@ -11,7 +11,7 @@ import SwiftUI struct VOErrorMessage: View { - let message: String? + private let message: String? init() { message = nil diff --git a/Sources/Library/Views/FormButtonLabel.swift b/Sources/Library/Views/FormButtonLabel.swift new file mode 100644 index 0000000..ed14bb3 --- /dev/null +++ b/Sources/Library/Views/FormButtonLabel.swift @@ -0,0 +1,40 @@ +// Copyright (c) 2024 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file LICENSE in the root of this repository. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// AGPL-3.0-only in the root of this repository. + +import SwiftUI + +struct VOFormButtonLabel: View { + private let text: String + private let isLoading: Bool + + init(_ text: String, isLoading: Bool = false) { + self.text = text + self.isLoading = isLoading + } + + var body: some View { + HStack { + Text(text) + if isLoading { + Spacer() + ProgressView() + } + } + } +} + +#Preview { + Form { + Button {} label: { + VOFormButtonLabel("Lorem Ipsum", isLoading: true) + } + .disabled(true) + } +} diff --git a/Sources/Library/Views/Logo.swift b/Sources/Library/Views/Logo.swift index c9d1f0c..544adbc 100644 --- a/Sources/Library/Views/Logo.swift +++ b/Sources/Library/Views/Logo.swift @@ -11,9 +11,14 @@ import SwiftUI struct VOLogo: View { - @Environment(\.colorScheme) var colorScheme - var isGlossy = false - var size: CGSize + @Environment(\.colorScheme) private var colorScheme + private let isGlossy: Bool + private let size: CGSize + + init(isGlossy: Bool = false, size: CGSize) { + self.isGlossy = isGlossy + self.size = size + } var body: some View { if colorScheme == .dark { diff --git a/Sources/Library/Views/PermissionBadge.swift b/Sources/Library/Views/PermissionBadge.swift index 95e99e0..da22427 100644 --- a/Sources/Library/Views/PermissionBadge.swift +++ b/Sources/Library/Views/PermissionBadge.swift @@ -12,7 +12,7 @@ import SwiftUI import VoltaserveCore struct VOPermissionBadge: View { - var permission: VOPermission.Value + private let permission: VOPermission.Value init(_ permission: VOPermission.Value) { self.permission = permission diff --git a/Sources/Library/Views/SectionHeader.swift b/Sources/Library/Views/SectionHeader.swift index dcc3094..08e8894 100644 --- a/Sources/Library/Views/SectionHeader.swift +++ b/Sources/Library/Views/SectionHeader.swift @@ -11,7 +11,7 @@ import SwiftUI struct VOSectionHeader: View { - var text: String + private let text: String init(_ text: String) { self.text = text diff --git a/Sources/Library/Views/WarningMessage.swift b/Sources/Library/Views/WarningMessage.swift index 52fee33..b6d6642 100644 --- a/Sources/Library/Views/WarningMessage.swift +++ b/Sources/Library/Views/WarningMessage.swift @@ -11,7 +11,7 @@ import SwiftUI struct VOWarningMessage: View { - let message: String? + private let message: String? init() { message = nil diff --git a/Sources/Protocol/ErrorPresentable.swift b/Sources/Protocol/ErrorPresentable.swift new file mode 100644 index 0000000..131e6fb --- /dev/null +++ b/Sources/Protocol/ErrorPresentable.swift @@ -0,0 +1,16 @@ +// Copyright (c) 2024 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file LICENSE in the root of this repository. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// AGPL-3.0-only in the root of this repository. + +import Foundation + +protocol ErrorPresentable { + var errorIsPresented: Bool { get set } + var errorMessage: String? { get set } +} diff --git a/Sources/Protocol/FormValidatable.swift b/Sources/Protocol/FormValidatable.swift new file mode 100644 index 0000000..7866e0f --- /dev/null +++ b/Sources/Protocol/FormValidatable.swift @@ -0,0 +1,15 @@ +// Copyright (c) 2024 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file LICENSE in the root of this repository. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// AGPL-3.0-only in the root of this repository. + +import Foundation + +protocol FormValidatable { + func isValid() -> Bool +} diff --git a/Sources/Protocol/LoadStateProvider.swift b/Sources/Protocol/LoadStateProvider.swift new file mode 100644 index 0000000..05c726f --- /dev/null +++ b/Sources/Protocol/LoadStateProvider.swift @@ -0,0 +1,16 @@ +// Copyright (c) 2024 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file LICENSE in the root of this repository. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// AGPL-3.0-only in the root of this repository. + +import Foundation + +protocol LoadStateProvider { + var isLoading: Bool { get } + var error: String? { get } +} diff --git a/Sources/Protocol/TimerLifecycle.swift b/Sources/Protocol/TimerLifecycle.swift new file mode 100644 index 0000000..fbc1aca --- /dev/null +++ b/Sources/Protocol/TimerLifecycle.swift @@ -0,0 +1,16 @@ +// Copyright (c) 2024 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file LICENSE in the root of this repository. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// AGPL-3.0-only in the root of this repository. + +import Foundation + +protocol TimerLifecycle { + func startTimers() + func stopTimers() +} diff --git a/Sources/Protocol/TokenDistributing.swift b/Sources/Protocol/TokenDistributing.swift new file mode 100644 index 0000000..32999c0 --- /dev/null +++ b/Sources/Protocol/TokenDistributing.swift @@ -0,0 +1,16 @@ +// Copyright (c) 2024 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file LICENSE in the root of this repository. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// AGPL-3.0-only in the root of this repository. + +import Foundation +import VoltaserveCore + +protocol TokenDistributing { + func assignTokenToStores(_ token: VOToken.Value) +} diff --git a/Sources/Protocol/ViewDataProvider.swift b/Sources/Protocol/ViewDataProvider.swift new file mode 100644 index 0000000..26f71e1 --- /dev/null +++ b/Sources/Protocol/ViewDataProvider.swift @@ -0,0 +1,16 @@ +// Copyright (c) 2024 Anass Bouassaba. +// +// Use of this software is governed by the Business Source License +// included in the file LICENSE in the root of this repository. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the GNU Affero General Public License v3.0 only, included in the file +// AGPL-3.0-only in the root of this repository. + +import Foundation + +protocol ViewDataProvider: LoadStateProvider { + func onAppearOrChange() + func fetchData() +} diff --git a/Sources/Screens/Account/AccountEditEmail.swift b/Sources/Screens/Account/AccountEditEmail.swift index 483f0f4..5f78c96 100644 --- a/Sources/Screens/Account/AccountEditEmail.swift +++ b/Sources/Screens/Account/AccountEditEmail.swift @@ -11,52 +11,53 @@ import SwiftUI import VoltaserveCore -struct AccountEditEmail: View { +struct AccountEditEmail: View, LoadStateProvider, FormValidatable, ErrorPresentable { @ObservedObject private var accountStore: AccountStore @Environment(\.dismiss) private var dismiss @State private var value = "" @State private var isSaving = false - @State private var showError = false - @State private var errorTitle: String? - @State private var errorMessage: String? init(accountStore: AccountStore) { self.accountStore = accountStore } var body: some View { - if let identityUser = accountStore.identityUser { - Form { - TextField("Email", text: $value) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .disabled(isSaving) - } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Change Email") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - if isSaving { - ProgressView() - } else { - Button("Save") { - performSave() + if isLoading { + ProgressView() + } else if let error { + VOErrorMessage(error) + } else { + if let identityUser = accountStore.identityUser { + Form { + TextField("Email", text: $value) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .disabled(isSaving) + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Change Email") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if isSaving { + ProgressView() + } else { + Button("Save") { + performSave() + } + .disabled(!isValid()) } - .disabled(!isValid()) } } - } - .voErrorAlert(isPresented: $showError, title: errorTitle, message: errorMessage) - .onAppear { - value = identityUser.pendingEmail ?? identityUser.email - } - .onChange(of: accountStore.identityUser) { _, newUser in - if let newUser { - value = newUser.pendingEmail ?? newUser.email + .voErrorSheet(isPresented: $errorIsPresented, message: errorMessage) + .onAppear { + value = identityUser.pendingEmail ?? identityUser.email + } + .onChange(of: accountStore.identityUser) { _, newUser in + if let newUser { + value = newUser.pendingEmail ?? newUser.email + } } } - } else { - ProgressView() } } @@ -64,13 +65,6 @@ struct AccountEditEmail: View { value.trimmingCharacters(in: .whitespaces) } - private func isValid() -> Bool { - if let identityUser = accountStore.identityUser { - return !normalizedValue.isEmpty && normalizedValue != (identityUser.pendingEmail ?? identityUser.email) - } - return false - } - private func performSave() { isSaving = true withErrorHandling { @@ -79,11 +73,34 @@ struct AccountEditEmail: View { } success: { dismiss() } failure: { message in - errorTitle = "Error: Saving Email" errorMessage = message - showError = true + errorIsPresented = true } anyways: { isSaving = false } } + + // MARK: - ErrorPresentable + + @State var errorIsPresented = false + @State var errorMessage: String? + + // MARK: - LoadStateProvider + + var isLoading: Bool { + accountStore.identityUserIsLoading + } + + var error: String? { + accountStore.identityUserError + } + + // MARK: - FormValidatable + + func isValid() -> Bool { + if let identityUser = accountStore.identityUser { + return !normalizedValue.isEmpty && normalizedValue != (identityUser.pendingEmail ?? identityUser.email) + } + return false + } } diff --git a/Sources/Screens/Account/AccountEditFullName.swift b/Sources/Screens/Account/AccountEditFullName.swift index 3f8d1ff..e56a6e9 100644 --- a/Sources/Screens/Account/AccountEditFullName.swift +++ b/Sources/Screens/Account/AccountEditFullName.swift @@ -11,50 +11,51 @@ import SwiftUI import VoltaserveCore -struct AccountEditFullName: View { +struct AccountEditFullName: View, LoadStateProvider, FormValidatable, ErrorPresentable { @ObservedObject private var accountStore: AccountStore @Environment(\.dismiss) private var dismiss @State private var value = "" @State private var isSaving = false - @State private var showError = false - @State private var errorTitle: String? - @State private var errorMessage: String? init(accountStore: AccountStore) { self.accountStore = accountStore } var body: some View { - if let user = accountStore.identityUser { - Form { - TextField("Full Name", text: $value) - .disabled(isSaving) - } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Change Full Name") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - if isSaving { - ProgressView() - } else { - Button("Save") { - performSave() + if isLoading { + ProgressView() + } else if let error { + VOErrorMessage(error) + } else { + if let identityUser = accountStore.identityUser { + Form { + TextField("Full Name", text: $value) + .disabled(isSaving) + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Change Full Name") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if isSaving { + ProgressView() + } else { + Button("Save") { + performSave() + } + .disabled(!isValid()) } - .disabled(!isValid()) } } - } - .voErrorAlert(isPresented: $showError, title: errorTitle, message: errorMessage) - .onAppear { - value = user.fullName - } - .onChange(of: accountStore.identityUser) { _, newUser in - if let newUser { - value = newUser.fullName + .voErrorSheet(isPresented: $errorIsPresented, message: errorMessage) + .onAppear { + value = identityUser.fullName + } + .onChange(of: accountStore.identityUser) { _, newUser in + if let newUser { + value = newUser.fullName + } } } - } else { - ProgressView() } } @@ -70,15 +71,31 @@ struct AccountEditFullName: View { } success: { dismiss() } failure: { message in - errorTitle = "Error: Saving Full Name" errorMessage = message - showError = true + errorIsPresented = true } anyways: { isSaving = false } } - private func isValid() -> Bool { + // MARK: - ErrorPresentable + + @State var errorIsPresented = false + @State var errorMessage: String? + + // MARK: - LoadStateProvider + + var isLoading: Bool { + accountStore.identityUserIsLoading + } + + var error: String? { + accountStore.identityUserError + } + + // MARK: - FormValidatable + + func isValid() -> Bool { if let identityUser = accountStore.identityUser { return !normalizedValue.isEmpty && normalizedValue != identityUser.fullName } diff --git a/Sources/Screens/Account/AccountEditPassword.swift b/Sources/Screens/Account/AccountEditPassword.swift index 1d47f44..f9031e1 100644 --- a/Sources/Screens/Account/AccountEditPassword.swift +++ b/Sources/Screens/Account/AccountEditPassword.swift @@ -11,15 +11,12 @@ import SwiftUI import VoltaserveCore -struct AccountEditPassword: View { +struct AccountEditPassword: View, FormValidatable, ErrorPresentable { @ObservedObject private var accountStore: AccountStore @Environment(\.dismiss) private var dismiss @State private var currentValue = "" @State private var newValue = "" @State private var isSaving = false - @State private var showError = false - @State private var errorTitle: String? - @State private var errorMessage: String? init(accountStore: AccountStore) { self.accountStore = accountStore @@ -46,7 +43,7 @@ struct AccountEditPassword: View { } } } - .voErrorAlert(isPresented: $showError, title: errorTitle, message: errorMessage) + .voErrorSheet(isPresented: $errorIsPresented, message: errorMessage) } private func performSave() { @@ -57,15 +54,21 @@ struct AccountEditPassword: View { } success: { dismiss() } failure: { message in - errorTitle = "Error: Saving Password" errorMessage = message - showError = true + errorIsPresented = true } anyways: { isSaving = false } } - private func isValid() -> Bool { + // MARK: - ErrorPresentable + + @State var errorIsPresented = false + @State var errorMessage: String? + + // MARK: - FormValidatable + + func isValid() -> Bool { !currentValue.isEmpty && !newValue.isEmpty } } diff --git a/Sources/Screens/Account/AccountOverview.swift b/Sources/Screens/Account/AccountOverview.swift index eb0724a..31cd618 100644 --- a/Sources/Screens/Account/AccountOverview.swift +++ b/Sources/Screens/Account/AccountOverview.swift @@ -11,30 +11,32 @@ import SwiftUI import VoltaserveCore -struct AccountOverview: View { +struct AccountOverview: View, ViewDataProvider, LoadStateProvider, TimerLifecycle, TokenDistributing { @EnvironmentObject private var tokenStore: TokenStore @StateObject private var accountStore = AccountStore() @StateObject private var invitationStore = InvitationStore() @Environment(\.dismiss) private var dismiss - @State private var showDelete = false - @State private var showError = false + @State private var deleteIsPresented = false var body: some View { NavigationStack { VStack { - if accountStore.identityUser == nil || - accountStore.storageUsage == nil { + if isLoading { ProgressView() - } else if let user = accountStore.identityUser { - VOAvatar( - name: user.fullName, - size: 100, - url: accountStore.urlForUserPicture( - user.id, - fileExtension: user.picture?.fileExtension + } else if let error { + VOErrorMessage(error) + } else { + if let identityUser = accountStore.identityUser { + VOAvatar( + name: identityUser.fullName, + size: 100, + url: accountStore.urlForUserPicture( + identityUser.id, + fileExtension: identityUser.picture?.fileExtension + ) ) - ) - .padding() + .padding() + } Form { Section(header: VOSectionHeader("Storage Usage")) { VStack(alignment: .leading) { @@ -42,9 +44,6 @@ struct AccountOverview: View { // swiftlint:disable:next line_length Text("\(storageUsage.bytes.prettyBytes()) of \(storageUsage.maxBytes.prettyBytes()) used") ProgressView(value: Double(storageUsage.percentage) / 100.0) - } else { - Text("Calculating…") - ProgressView() } } } @@ -59,8 +58,8 @@ struct AccountOverview: View { HStack { Label("Invitations", systemImage: "paperplane") Spacer() - if let count = invitationStore.incomingCount, count > 0 { - VONumberBadge(count) + if let incomingCount = invitationStore.incomingCount, incomingCount > 0 { + VONumberBadge(incomingCount) } } } @@ -83,11 +82,6 @@ struct AccountOverview: View { } } } - .voErrorAlert( - isPresented: $showError, - title: accountStore.errorTitle, - message: accountStore.errorMessage - ) .onAppear { accountStore.tokenStore = tokenStore if let token = tokenStore.token { @@ -105,38 +99,56 @@ struct AccountOverview: View { onAppearOrChange() } } - .sync($accountStore.showError, with: $showError) } - private func onAppearOrChange() { + private func performSignOut() { + tokenStore.token = nil + tokenStore.deleteFromKeychain() + dismiss() + } + + // MARK: - LoadStateProvider + + var isLoading: Bool { + accountStore.identityUserIsLoading || + accountStore.storageUsageIsLoading || + invitationStore.incomingCountIsLoading + } + + var error: String? { + accountStore.identityUserError ?? + accountStore.storageUsageError ?? + invitationStore.incomingCountError + } + + // MARK: - ViewDataProvider + + func onAppearOrChange() { fetchData() } - private func fetchData() { - accountStore.fetchUser() + func fetchData() { + accountStore.fetchIdentityUser() accountStore.fetchAccountStorageUsage() invitationStore.fetchIncomingCount() } - private func startTimers() { - accountStore.startTimer() + // MARK: - TimerLifecycle + + func startTimers() { accountStore.startTimer() invitationStore.startTimer() } - private func stopTimers() { + func stopTimers() { accountStore.stopTimer() invitationStore.stopTimer() } - private func assignTokenToStores(_ token: VOToken.Value) { + // MARK: - TokenDistributing + + func assignTokenToStores(_ token: VOToken.Value) { accountStore.token = token invitationStore.token = token } - - private func performSignOut() { - tokenStore.token = nil - tokenStore.deleteFromKeychain() - dismiss() - } } diff --git a/Sources/Screens/Account/AccountSettings.swift b/Sources/Screens/Account/AccountSettings.swift index ff2d3d1..292ceb7 100644 --- a/Sources/Screens/Account/AccountSettings.swift +++ b/Sources/Screens/Account/AccountSettings.swift @@ -11,15 +11,12 @@ import SwiftUI import VoltaserveCore -struct AccountSettings: View { +struct AccountSettings: View, ViewDataProvider, LoadStateProvider, ErrorPresentable { @EnvironmentObject private var tokenStore: TokenStore @ObservedObject private var accountStore: AccountStore @Environment(\.dismiss) private var dismiss - @State private var showDeleteConfirmation = false - @State private var showDeleteNotice = false - @State private var showError = false - @State private var errorTitle: String? - @State private var errorMessage: String? + @State private var deleteConfirmationIsPresented = false + @State private var deleteNoticeIsPresented = false @State private var password = "" @State private var isDeleting = false private let onDelete: (() -> Void)? @@ -31,17 +28,18 @@ struct AccountSettings: View { var body: some View { VStack { - if accountStore.identityUser == nil || - accountStore.storageUsage == nil { + if isLoading { ProgressView() - } else if let user = accountStore.identityUser { + } else if let error { + VOErrorMessage(error) + } else if let identityUser = accountStore.identityUser { Form { Section(header: VOSectionHeader("Basics")) { NavigationLink(destination: AccountEditFullName(accountStore: accountStore)) { HStack { Text("Full name") Spacer() - Text(user.fullName) + Text(identityUser.fullName) .lineLimit(1) .truncationMode(.tail) .foregroundStyle(.secondary) @@ -54,14 +52,14 @@ struct AccountSettings: View { HStack { Text("Email") Spacer() - Text(user.pendingEmail ?? user.email) + Text(identityUser.pendingEmail ?? identityUser.email) .lineLimit(1) .truncationMode(.middle) .foregroundStyle(.secondary) } } .disabled(isDeleting) - if user.pendingEmail != nil { + if identityUser.pendingEmail != nil { HStack(spacing: VOMetrics.spacingXs) { Image(systemName: "exclamationmark.triangle") .foregroundStyle(Color.yellow400) @@ -84,30 +82,25 @@ struct AccountSettings: View { .disabled(isDeleting) Button(role: .destructive) { if password.isEmpty { - showDeleteNotice = true + deleteNoticeIsPresented = true } else { - showDeleteConfirmation = true + deleteConfirmationIsPresented = true } } label: { - HStack { - Text("Delete Account") - if isDeleting { - Spacer() - ProgressView() - } - } + VOFormButtonLabel("Delete Account", isLoading: isDeleting) } .disabled(isDeleting) - .confirmationDialog("Delete Account", isPresented: $showDeleteConfirmation) { + .confirmationDialog("Delete Account", isPresented: $deleteConfirmationIsPresented) { Button("Delete Permanently", role: .destructive) { performDelete() } } message: { Text("Are you sure want to delete your account?") } - .alert("Missing Password Confirmation", isPresented: $showDeleteNotice) { - Button("OK") {} - } message: { + .confirmationDialog( + "Missing Password Confirmation", + isPresented: $deleteNoticeIsPresented + ) {} message: { Text("You need to enter your password to confirm the account deletion.") } } @@ -116,16 +109,7 @@ struct AccountSettings: View { } .navigationBarTitleDisplayMode(.inline) .navigationTitle("Settings") - .voErrorAlert( - isPresented: $showError, - title: errorTitle, - message: errorMessage - ) - .voErrorAlert( - isPresented: $accountStore.showError, - title: accountStore.errorTitle, - message: accountStore.errorMessage - ) + .voErrorSheet(isPresented: $errorIsPresented, message: errorMessage) .onAppear { accountStore.tokenStore = tokenStore if tokenStore.token != nil { @@ -139,14 +123,6 @@ struct AccountSettings: View { } } - private func onAppearOrChange() { - fetchData() - } - - private func fetchData() { - accountStore.fetchUser() - } - private func performDelete() { isDeleting = true withErrorHandling { @@ -156,11 +132,35 @@ struct AccountSettings: View { dismiss() onDelete?() } failure: { message in - errorTitle = "Error: Deleting Account" errorMessage = message - showError = true + errorIsPresented = true } anyways: { isDeleting = false } } + + // MARK: - ErrorPresentable + + @State var errorIsPresented = false + @State var errorMessage: String? + + // MARK: - LoadStateProvider + + var isLoading: Bool { + accountStore.identityUserIsLoading + } + + var error: String? { + accountStore.identityUserError + } + + // MARK: - ViewDataProvider + + func onAppearOrChange() { + fetchData() + } + + func fetchData() { + accountStore.fetchIdentityUser() + } } diff --git a/Sources/Screens/Account/AccountStore.swift b/Sources/Screens/Account/AccountStore.swift index d813593..5eb196f 100644 --- a/Sources/Screens/Account/AccountStore.swift +++ b/Sources/Screens/Account/AccountStore.swift @@ -13,10 +13,11 @@ import VoltaserveCore class AccountStore: ObservableObject { @Published var identityUser: VOIdentityUser.Entity? + @Published var identityUserError: String? + @Published var identityUserIsLoading: Bool = false @Published var storageUsage: VOStorage.Usage? - @Published var showError = false - @Published var errorTitle: String? - @Published var errorMessage: String? + @Published var storageUsageError: String? + @Published var storageUsageIsLoading: Bool = false private var timer: Timer? private var accountClient: VOAccount? private var identityUserClient: VOIdentityUser? @@ -52,26 +53,28 @@ class AccountStore: ObservableObject { return nil } - private func fetchUser() async throws -> VOIdentityUser.Entity? { + private func fetchIdentityUser() async throws -> VOIdentityUser.Entity? { try await identityUserClient?.fetch() } // MARK: - Fetch - func fetchUser() { - var user: VOIdentityUser.Entity? + func fetchIdentityUser() { + var identityUser: VOIdentityUser.Entity? withErrorHandling { - user = try await self.fetchUser() + identityUser = try await self.fetchIdentityUser() return true + } before: { + self.identityUserIsLoading = true } success: { - self.identityUser = user + self.identityUser = identityUser } failure: { message in - self.errorTitle = "Error: Fetching User" - self.errorMessage = message - self.showError = true - } invalidCreditentials: { + self.identityUserError = message + } invalidCredentials: { self.tokenStore?.token = nil self.tokenStore?.deleteFromKeychain() + } anyways: { + self.identityUserIsLoading = false } } @@ -80,16 +83,18 @@ class AccountStore: ObservableObject { } func fetchAccountStorageUsage() { - var usage: VOStorage.Usage? + var storageUsage: VOStorage.Usage? withErrorHandling { - usage = try await self.fetchAccountStorageUsage() + storageUsage = try await self.fetchAccountStorageUsage() return true + } before: { + self.storageUsageIsLoading = true } success: { - self.storageUsage = usage + self.storageUsage = storageUsage } failure: { message in - self.errorTitle = "Error: Fetching Storage Usage" - self.errorMessage = message - self.showError = true + self.storageUsageError = message + } anyways: { + self.storageUsageIsLoading = false } } @@ -118,7 +123,7 @@ class AccountStore: ObservableObject { timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in if self.identityUser != nil { Task { - let user = try await self.fetchUser() + let user = try await self.fetchIdentityUser() if let user { DispatchQueue.main.async { self.identityUser = user diff --git a/Sources/Screens/Browser/BrowserList.swift b/Sources/Screens/Browser/BrowserList.swift index 761de99..9da5010 100644 --- a/Sources/Screens/Browser/BrowserList.swift +++ b/Sources/Screens/Browser/BrowserList.swift @@ -79,7 +79,7 @@ struct BrowserList: View { } } .refreshable { - browserStore.fetchNext(replace: true) + browserStore.fetchNextPage(replace: true) } } else { ProgressView() @@ -123,7 +123,7 @@ struct BrowserList: View { } .onChange(of: browserStore.query) { browserStore.clear() - browserStore.fetchNext() + browserStore.fetchNextPage() } .sync($browserStore.searchText, with: $searchText) .sync($browserStore.showError, with: $showError) @@ -135,7 +135,7 @@ struct BrowserList: View { private func fetchData() { browserStore.fetch() - browserStore.fetchNext(replace: true) + browserStore.fetchNextPage(replace: true) } private func startTimers() { @@ -152,7 +152,7 @@ struct BrowserList: View { private func onListItemAppear(_ id: String) { if browserStore.isEntityThreshold(id) { - browserStore.fetchNext() + browserStore.fetchNextPage() } } } diff --git a/Sources/Screens/Browser/BrowserStore.swift b/Sources/Screens/Browser/BrowserStore.swift index 2c6d188..44d6ed5 100644 --- a/Sources/Screens/Browser/BrowserStore.swift +++ b/Sources/Screens/Browser/BrowserStore.swift @@ -79,7 +79,7 @@ class BrowserStore: ObservableObject { try await fileClient?.fetchList(id, options: .init(query: query, page: page, size: size, type: .folder)) } - func fetchNext(replace: Bool = false) { + func fetchNextPage(replace: Bool = false) { guard let fileID else { return } guard !isLoading else { return } diff --git a/Sources/Screens/File/FileCopy.swift b/Sources/Screens/File/FileCopy.swift index e0d479a..7dd8262 100644 --- a/Sources/Screens/File/FileCopy.swift +++ b/Sources/Screens/File/FileCopy.swift @@ -68,7 +68,7 @@ struct FileCopy: View { withErrorHandling(delaySeconds: 1) { result = try await fileStore.copy(Array(fileStore.selection), to: destinationID) if fileStore.isLastPage() { - fileStore.fetchNext() + fileStore.fetchNextPage() } if let result { if result.failed.isEmpty { diff --git a/Sources/Screens/File/FileGrid.swift b/Sources/Screens/File/FileGrid.swift index 463171b..7e8cd8b 100644 --- a/Sources/Screens/File/FileGrid.swift +++ b/Sources/Screens/File/FileGrid.swift @@ -74,7 +74,7 @@ struct FileGrid: View { private func onListItemAppear(_ id: String) { if fileStore.isEntityThreshold(id) { - fileStore.fetchNext() + fileStore.fetchNextPage() } } } diff --git a/Sources/Screens/File/FileList.swift b/Sources/Screens/File/FileList.swift index bcf985d..5287630 100644 --- a/Sources/Screens/File/FileList.swift +++ b/Sources/Screens/File/FileList.swift @@ -62,7 +62,7 @@ struct FileList: View { private func onListItemAppear(_ id: String) { if fileStore.isEntityThreshold(id) { - fileStore.fetchNext() + fileStore.fetchNextPage() } } } diff --git a/Sources/Screens/File/FileOverview.swift b/Sources/Screens/File/FileOverview.swift index 7f45ba6..bac3539 100644 --- a/Sources/Screens/File/FileOverview.swift +++ b/Sources/Screens/File/FileOverview.swift @@ -42,7 +42,7 @@ struct FileOverview: View { } .searchable(text: $searchText) .onChange(of: fileStore.searchText) { fileStore.searchPublisher.send($1) } - .refreshable { fileStore.fetchNext(replace: true) } + .refreshable { fileStore.fetchNextPage(replace: true) } } else { ProgressView() } @@ -74,7 +74,7 @@ struct FileOverview: View { } .onChange(of: fileStore.query) { fileStore.clear() - fileStore.fetchNext(replace: true) + fileStore.fetchNextPage(replace: true) } .sync($fileStore.searchText, with: $searchText) .sync($fileStore.showError, with: $showError) @@ -86,7 +86,7 @@ struct FileOverview: View { private func fetchData() { fileStore.fetch() - fileStore.fetchNext(replace: true) + fileStore.fetchNextPage(replace: true) fileStore.fetchTaskCount() } diff --git a/Sources/Screens/File/FileStore.swift b/Sources/Screens/File/FileStore.swift index c27fe6f..3fd9ea5 100644 --- a/Sources/Screens/File/FileStore.swift +++ b/Sources/Screens/File/FileStore.swift @@ -128,7 +128,7 @@ class FileStore: ObservableObject { try await fileClient?.fetchList(id, options: .init(query: query, page: page, size: size)) } - func fetchNext(replace: Bool = false) { + func fetchNextPage(replace: Bool = false) { guard let current else { return } guard !isLoading else { return } diff --git a/Sources/Screens/File/FileUpload.swift b/Sources/Screens/File/FileUpload.swift index 81b6e19..7ed8c5d 100644 --- a/Sources/Screens/File/FileUpload.swift +++ b/Sources/Screens/File/FileUpload.swift @@ -79,7 +79,7 @@ struct FileUpload: View { } _ = try await fileStore.upload(url, workspaceID: workspace.id) if fileStore.isLastPage() { - fileStore.fetchNext() + fileStore.fetchNextPage() } url.stopAccessingSecurityScopedResource() dispatchGroup.leave() diff --git a/Sources/Screens/File/FolderCreate.swift b/Sources/Screens/File/FolderCreate.swift index cee9b51..251c77e 100644 --- a/Sources/Screens/File/FolderCreate.swift +++ b/Sources/Screens/File/FolderCreate.swift @@ -70,7 +70,7 @@ struct FolderCreate: View { parentID: parentID ) if fileStore.isLastPage() { - fileStore.fetchNext() + fileStore.fetchNextPage() } return true } success: { diff --git a/Sources/Screens/Group/GroupList.swift b/Sources/Screens/Group/GroupList.swift index 4bc53ab..a986af1 100644 --- a/Sources/Screens/Group/GroupList.swift +++ b/Sources/Screens/Group/GroupList.swift @@ -48,7 +48,7 @@ struct GroupList: View { } .navigationTitle("Groups") .refreshable { - groupStore.fetchNext(replace: true) + groupStore.fetchNextPage(replace: true) } .toolbar { ToolbarItem(placement: .topBarLeading) { @@ -102,7 +102,7 @@ struct GroupList: View { } .onChange(of: groupStore.query) { groupStore.clear() - groupStore.fetchNext() + groupStore.fetchNextPage() } .sync($groupStore.searchText, with: $searchText) .sync($groupStore.showError, with: $showError) @@ -113,7 +113,7 @@ struct GroupList: View { } private func fetchData() { - groupStore.fetchNext(replace: true) + groupStore.fetchNextPage(replace: true) } private func startTimers() { @@ -130,7 +130,7 @@ struct GroupList: View { private func onListItemAppear(_ id: String) { if groupStore.isEntityThreshold(id) { - groupStore.fetchNext() + groupStore.fetchNextPage() } } } diff --git a/Sources/Screens/Group/GroupMemberList.swift b/Sources/Screens/Group/GroupMemberList.swift index 2497e43..bb86636 100644 --- a/Sources/Screens/Group/GroupMemberList.swift +++ b/Sources/Screens/Group/GroupMemberList.swift @@ -52,7 +52,7 @@ struct GroupMemberList: View { } } .refreshable { - userStore.fetchNext(replace: true) + userStore.fetchNextPage(replace: true) } .toolbar { ToolbarItem(placement: .topBarLeading) { @@ -98,7 +98,7 @@ struct GroupMemberList: View { } .onChange(of: userStore.query) { userStore.clear() - userStore.fetchNext() + userStore.fetchNextPage() } .sync($userStore.searchText, with: $searchText) .sync($userStore.showError, with: $showError) @@ -109,7 +109,7 @@ struct GroupMemberList: View { } private func fetchData() { - userStore.fetchNext(replace: true) + userStore.fetchNextPage(replace: true) } private func startTimers() { @@ -126,7 +126,7 @@ struct GroupMemberList: View { private func onListItemAppear(_ id: String) { if userStore.isEntityThreshold(id) { - userStore.fetchNext() + userStore.fetchNextPage() } } } diff --git a/Sources/Screens/Group/GroupSelector.swift b/Sources/Screens/Group/GroupSelector.swift index bbdfbf6..0e52af0 100644 --- a/Sources/Screens/Group/GroupSelector.swift +++ b/Sources/Screens/Group/GroupSelector.swift @@ -55,7 +55,7 @@ struct GroupSelector: View { } } .refreshable { - groupStore.fetchNext(replace: true) + groupStore.fetchNextPage(replace: true) } .voErrorAlert( isPresented: $showError, @@ -94,7 +94,7 @@ struct GroupSelector: View { } .onChange(of: groupStore.query) { groupStore.clear() - groupStore.fetchNext() + groupStore.fetchNextPage() } .sync($groupStore.showError, with: $showError) .sync($groupStore.searchText, with: $searchText) @@ -106,12 +106,12 @@ struct GroupSelector: View { private func onListItemAppear(_ id: String) { if groupStore.isEntityThreshold(id) { - groupStore.fetchNext() + groupStore.fetchNextPage() } } private func fetchData() { - groupStore.fetchNext(replace: true) + groupStore.fetchNextPage(replace: true) } private func startTimers() { diff --git a/Sources/Screens/Group/GroupSettings.swift b/Sources/Screens/Group/GroupSettings.swift index 2d29b40..04a02a8 100644 --- a/Sources/Screens/Group/GroupSettings.swift +++ b/Sources/Screens/Group/GroupSettings.swift @@ -55,13 +55,7 @@ struct GroupSettings: View { Button(role: .destructive) { showDeleteConfirmation = true } label: { - HStack { - Text("Delete Group") - if isDeleting { - Spacer() - ProgressView() - } - } + VOFormButtonLabel("Delete Group", isLoading: isDeleting) } .disabled(isDeleting) .confirmationDialog("Delete Group", isPresented: $showDeleteConfirmation) { diff --git a/Sources/Screens/Group/GroupStore.swift b/Sources/Screens/Group/GroupStore.swift index 678934e..d4d2ae8 100644 --- a/Sources/Screens/Group/GroupStore.swift +++ b/Sources/Screens/Group/GroupStore.swift @@ -88,7 +88,7 @@ class GroupStore: ObservableObject { } } - func fetchNext(replace: Bool = false) { + func fetchNextPage(replace: Bool = false) { guard !isLoading else { return } var nextPage = -1 diff --git a/Sources/Screens/Insights/InsightsChart.swift b/Sources/Screens/Insights/InsightsChart.swift index ec4bac3..372572f 100644 --- a/Sources/Screens/Insights/InsightsChart.swift +++ b/Sources/Screens/Insights/InsightsChart.swift @@ -55,7 +55,7 @@ struct InsightsChart: View { .navigationBarTitleDisplayMode(.inline) .navigationTitle("Insights") .refreshable { - insightsStore.fetchEntityNext(replace: true) + insightsStore.fetchEntityNextPage(replace: true) } .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -93,7 +93,7 @@ struct InsightsChart: View { } private func fetchData() { - insightsStore.fetchEntityNext(replace: true) + insightsStore.fetchEntityNextPage(replace: true) } private func startTimers() { diff --git a/Sources/Screens/Insights/InsightsEntityList.swift b/Sources/Screens/Insights/InsightsEntityList.swift index 6c7b84c..d7ce0aa 100644 --- a/Sources/Screens/Insights/InsightsEntityList.swift +++ b/Sources/Screens/Insights/InsightsEntityList.swift @@ -47,7 +47,7 @@ struct InsightsEntityList: View { .navigationBarTitleDisplayMode(.inline) .navigationTitle("Insights") .refreshable { - insightsStore.fetchEntityNext(replace: true) + insightsStore.fetchEntityNextPage(replace: true) } .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -84,7 +84,7 @@ struct InsightsEntityList: View { } .onChange(of: insightsStore.query) { insightsStore.clear() - insightsStore.fetchEntityNext() + insightsStore.fetchEntityNextPage() } .sync($insightsStore.searchText, with: $searchText) .sync($insightsStore.showError, with: $showError) @@ -95,7 +95,7 @@ struct InsightsEntityList: View { } private func fetchData() { - insightsStore.fetchEntityNext(replace: true) + insightsStore.fetchEntityNextPage(replace: true) } private func startTimers() { @@ -112,7 +112,7 @@ struct InsightsEntityList: View { private func onListItemAppear(_ id: String) { if insightsStore.isEntityThreshold(id) { - insightsStore.fetchEntityNext() + insightsStore.fetchEntityNextPage() } } } diff --git a/Sources/Screens/Insights/InsightsStore.swift b/Sources/Screens/Insights/InsightsStore.swift index 374b2bb..a6e8071 100644 --- a/Sources/Screens/Insights/InsightsStore.swift +++ b/Sources/Screens/Insights/InsightsStore.swift @@ -118,7 +118,7 @@ class InsightsStore: ObservableObject { ) } - func fetchEntityNext(replace: Bool = false) { + func fetchEntityNextPage(replace: Bool = false) { var nextPage = -1 var list: VOInsights.EntityList? diff --git a/Sources/Screens/Invitation/InvitationIncomingList.swift b/Sources/Screens/Invitation/InvitationIncomingList.swift index 5eadb4b..0696483 100644 --- a/Sources/Screens/Invitation/InvitationIncomingList.swift +++ b/Sources/Screens/Invitation/InvitationIncomingList.swift @@ -43,7 +43,7 @@ struct InvitationIncomingList: View { } } .refreshable { - invitationStore.fetchNext(replace: true) + invitationStore.fetchNextPage(replace: true) } } else { ProgressView() @@ -53,7 +53,7 @@ struct InvitationIncomingList: View { .navigationTitle("Invitations") .toolbar { ToolbarItem(placement: .topBarLeading) { - if invitationStore.isLoading, invitationStore.entities != nil { + if invitationStore.entitiesIsLoading, invitationStore.entities != nil { ProgressView() } } @@ -81,7 +81,7 @@ struct InvitationIncomingList: View { } private func fetchData() { - invitationStore.fetchNext(replace: true) + invitationStore.fetchNextPage(replace: true) } private func startTimers() { @@ -98,7 +98,7 @@ struct InvitationIncomingList: View { private func onListItemAppear(_ id: String) { if invitationStore.isEntityThreshold(id) { - invitationStore.fetchNext() + invitationStore.fetchNextPage() } } } diff --git a/Sources/Screens/Invitation/InvitationOutgoingList.swift b/Sources/Screens/Invitation/InvitationOutgoingList.swift index 3afcc51..1f3d221 100644 --- a/Sources/Screens/Invitation/InvitationOutgoingList.swift +++ b/Sources/Screens/Invitation/InvitationOutgoingList.swift @@ -49,7 +49,7 @@ struct InvitationOutgoingList: View { } } .refreshable { - invitationStore.fetchNext(replace: true) + invitationStore.fetchNextPage(replace: true) } } else { ProgressView() @@ -65,7 +65,7 @@ struct InvitationOutgoingList: View { } } ToolbarItem(placement: .topBarLeading) { - if invitationStore.isLoading, invitationStore.entities != nil { + if invitationStore.entitiesIsLoading, invitationStore.entities != nil { ProgressView() } } @@ -97,7 +97,7 @@ struct InvitationOutgoingList: View { } private func fetchData() { - invitationStore.fetchNext(replace: true) + invitationStore.fetchNextPage(replace: true) } private func startTimers() { @@ -117,7 +117,7 @@ struct InvitationOutgoingList: View { private func onListItemAppear(_ id: String) { if invitationStore.isEntityThreshold(id) { - invitationStore.fetchNext() + invitationStore.fetchNextPage() } } } diff --git a/Sources/Screens/Invitation/InvitationStore.swift b/Sources/Screens/Invitation/InvitationStore.swift index f5f7316..82bcca8 100644 --- a/Sources/Screens/Invitation/InvitationStore.swift +++ b/Sources/Screens/Invitation/InvitationStore.swift @@ -14,11 +14,11 @@ import VoltaserveCore class InvitationStore: ObservableObject { @Published var entities: [VOInvitation.Entity]? + @Published var entitiesError: String? + @Published var entitiesIsLoading: Bool = false @Published var incomingCount: Int? - @Published var showError = false - @Published var errorTitle: String? - @Published var errorMessage: String? - @Published var isLoading = false + @Published var incomingCountError: String? + @Published var incomingCountIsLoading: Bool = false private var list: VOInvitation.List? private var timer: Timer? private var invitationClient: VOInvitation? @@ -53,8 +53,8 @@ class InvitationStore: ObservableObject { } } - func fetchNext(replace: Bool = false) { - guard !isLoading else { return } + func fetchNextPage(replace: Bool = false) { + guard !entitiesIsLoading else { return } var nextPage = -1 var list: VOInvitation.List? @@ -77,7 +77,7 @@ class InvitationStore: ObservableObject { list = try await self.fetchList(page: nextPage) return true } before: { - self.isLoading = true + self.entitiesIsLoading = true } success: { self.list = list if let list { @@ -88,11 +88,9 @@ class InvitationStore: ObservableObject { } } } failure: { message in - self.errorTitle = "Error: Fetching Invitations" - self.errorMessage = message - self.showError = true + self.entitiesError = message } anyways: { - self.isLoading = false + self.entitiesIsLoading = false } } @@ -101,16 +99,18 @@ class InvitationStore: ObservableObject { } func fetchIncomingCount() { - var count: Int? + var incomingCount: Int? withErrorHandling { - count = try await self.fetchIncomingCount() + incomingCount = try await self.fetchIncomingCount() return true + } before: { + self.incomingCountIsLoading = true } success: { - self.incomingCount = count + self.incomingCount = incomingCount } failure: { message in - self.errorTitle = "Error: Fetching Invitation Incoming Count" - self.errorMessage = message - self.showError = true + self.incomingCountError = message + } anyways: { + self.incomingCountIsLoading = false } } diff --git a/Sources/Screens/Organization/OrganizationList.swift b/Sources/Screens/Organization/OrganizationList.swift index 8b8c7ca..afe377d 100644 --- a/Sources/Screens/Organization/OrganizationList.swift +++ b/Sources/Screens/Organization/OrganizationList.swift @@ -48,7 +48,7 @@ struct OrganizationList: View { } } .refreshable { - organizationStore.fetchNext(replace: true) + organizationStore.fetchNextPage(replace: true) } .toolbar { ToolbarItem(placement: .topBarLeading) { @@ -102,7 +102,7 @@ struct OrganizationList: View { } .onChange(of: organizationStore.query) { organizationStore.clear() - organizationStore.fetchNext() + organizationStore.fetchNextPage() } .sync($organizationStore.searchText, with: $searchText) .sync($organizationStore.showError, with: $showError) @@ -113,7 +113,7 @@ struct OrganizationList: View { } private func fetchData() { - organizationStore.fetchNext(replace: true) + organizationStore.fetchNextPage(replace: true) } private func startTimers() { @@ -130,7 +130,7 @@ struct OrganizationList: View { private func onListItemAppear(_ id: String) { if organizationStore.isEntityThreshold(id) { - organizationStore.fetchNext() + organizationStore.fetchNextPage() } } } diff --git a/Sources/Screens/Organization/OrganizationMemberList.swift b/Sources/Screens/Organization/OrganizationMemberList.swift index 2aa0914..a630608 100644 --- a/Sources/Screens/Organization/OrganizationMemberList.swift +++ b/Sources/Screens/Organization/OrganizationMemberList.swift @@ -50,7 +50,7 @@ struct OrganizationMemberList: View { } } .refreshable { - userStore.fetchNext(replace: true) + userStore.fetchNextPage(replace: true) } .sheet(isPresented: $showInviteMembers) { Text("Add Member") @@ -87,7 +87,7 @@ struct OrganizationMemberList: View { } .onChange(of: userStore.query) { userStore.clear() - userStore.fetchNext() + userStore.fetchNextPage() } .sync($userStore.searchText, with: $searchText) .sync($userStore.showError, with: $showError) @@ -98,7 +98,7 @@ struct OrganizationMemberList: View { } private func fetchData() { - userStore.fetchNext(replace: true) + userStore.fetchNextPage(replace: true) } private func startTimers() { @@ -115,7 +115,7 @@ struct OrganizationMemberList: View { private func onListItemAppear(_ id: String) { if userStore.isEntityThreshold(id) { - userStore.fetchNext() + userStore.fetchNextPage() } } } diff --git a/Sources/Screens/Organization/OrganizationSelector.swift b/Sources/Screens/Organization/OrganizationSelector.swift index 3dae2cd..eb96370 100644 --- a/Sources/Screens/Organization/OrganizationSelector.swift +++ b/Sources/Screens/Organization/OrganizationSelector.swift @@ -53,7 +53,7 @@ struct OrganizationSelector: View { .navigationBarTitleDisplayMode(.inline) .navigationTitle("Select Organization") .refreshable { - organizationStore.fetchNext(replace: true) + organizationStore.fetchNextPage(replace: true) } .toolbar { ToolbarItem(placement: .topBarLeading) { @@ -90,7 +90,7 @@ struct OrganizationSelector: View { } .onChange(of: organizationStore.query) { organizationStore.clear() - organizationStore.fetchNext() + organizationStore.fetchNextPage() } .sync($organizationStore.showError, with: $showError) .sync($organizationStore.searchText, with: $searchText) @@ -101,7 +101,7 @@ struct OrganizationSelector: View { } private func fetchData() { - organizationStore.fetchNext(replace: true) + organizationStore.fetchNextPage(replace: true) } private func startTimers() { @@ -118,7 +118,7 @@ struct OrganizationSelector: View { private func onListItemAppear(_ id: String) { if organizationStore.isEntityThreshold(id) { - organizationStore.fetchNext() + organizationStore.fetchNextPage() } } } diff --git a/Sources/Screens/Organization/OrganizationSettings.swift b/Sources/Screens/Organization/OrganizationSettings.swift index 8427e2c..b5ae094 100644 --- a/Sources/Screens/Organization/OrganizationSettings.swift +++ b/Sources/Screens/Organization/OrganizationSettings.swift @@ -57,13 +57,7 @@ struct OrganizationSettings: View { Button(role: .destructive) { showDeleteConfirmation = true } label: { - HStack { - Text("Delete Organization") - if isDeleting { - Spacer() - ProgressView() - } - } + VOFormButtonLabel("Delete Organization", isLoading: isDeleting) } .disabled(isDeleting) .confirmationDialog("Delete Organization", isPresented: $showDeleteConfirmation) { diff --git a/Sources/Screens/Organization/OrganizationStore.swift b/Sources/Screens/Organization/OrganizationStore.swift index 7755fa9..bf593ed 100644 --- a/Sources/Screens/Organization/OrganizationStore.swift +++ b/Sources/Screens/Organization/OrganizationStore.swift @@ -62,7 +62,7 @@ class OrganizationStore: ObservableObject { try await organizationClient?.fetchList(.init(query: query, page: page, size: size)) } - func fetchNext(replace: Bool = false) { + func fetchNextPage(replace: Bool = false) { guard !isLoading else { return } var nextPage = -1 diff --git a/Sources/Screens/Snapshot/SnapshotList.swift b/Sources/Screens/Snapshot/SnapshotList.swift index 49bf766..3a86961 100644 --- a/Sources/Screens/Snapshot/SnapshotList.swift +++ b/Sources/Screens/Snapshot/SnapshotList.swift @@ -46,7 +46,7 @@ struct SnapshotList: View { .navigationBarTitleDisplayMode(.inline) .navigationTitle("Snapshots") .refreshable { - snapshotStore.fetchNext(replace: true) + snapshotStore.fetchNextPage(replace: true) } .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -94,7 +94,7 @@ struct SnapshotList: View { } private func fetchData() { - snapshotStore.fetchNext(replace: true) + snapshotStore.fetchNextPage(replace: true) } private func assignTokenToStores(_ token: VOToken.Value) { @@ -111,7 +111,7 @@ struct SnapshotList: View { private func onListItemAppear(_ id: String) { if snapshotStore.isEntityThreshold(id) { - snapshotStore.fetchNext() + snapshotStore.fetchNextPage() } } } diff --git a/Sources/Screens/Snapshot/SnapshotStore.swift b/Sources/Screens/Snapshot/SnapshotStore.swift index 37571e9..e8f3d56 100644 --- a/Sources/Screens/Snapshot/SnapshotStore.swift +++ b/Sources/Screens/Snapshot/SnapshotStore.swift @@ -56,7 +56,7 @@ class SnapshotStore: ObservableObject { )) } - func fetchNext(replace: Bool = false) { + func fetchNextPage(replace: Bool = false) { guard !isLoading else { return } var nextPage = -1 diff --git a/Sources/Screens/Task/TaskList.swift b/Sources/Screens/Task/TaskList.swift index 43581a6..7d82297 100644 --- a/Sources/Screens/Task/TaskList.swift +++ b/Sources/Screens/Task/TaskList.swift @@ -49,7 +49,7 @@ struct TaskList: View { .navigationBarTitleDisplayMode(.inline) .navigationTitle("Tasks") .refreshable { - taskStore.fetchNext(replace: true) + taskStore.fetchNextPage(replace: true) } .toolbar { ToolbarItem(placement: .topBarLeading) { @@ -105,7 +105,7 @@ struct TaskList: View { } private func fetchData() { - taskStore.fetchNext(replace: true) + taskStore.fetchNextPage(replace: true) } private func assignTokenToStores(_ token: VOToken.Value) { @@ -122,7 +122,7 @@ struct TaskList: View { private func onListItemAppear(_ id: String) { if taskStore.isEntityThreshold(id) { - taskStore.fetchNext() + taskStore.fetchNextPage() } } @@ -137,7 +137,7 @@ struct TaskList: View { } return true } success: { - taskStore.fetchNext(replace: true) + taskStore.fetchNextPage(replace: true) dismiss() } failure: { message in errorTitle = "Error: Dismissing All Tasks" diff --git a/Sources/Screens/Task/TaskStore.swift b/Sources/Screens/Task/TaskStore.swift index ce18fb4..e1616f6 100644 --- a/Sources/Screens/Task/TaskStore.swift +++ b/Sources/Screens/Task/TaskStore.swift @@ -47,7 +47,7 @@ class TaskStore: ObservableObject { try await taskClient?.fetchList(.init(page: page, size: size)) } - func fetchNext(replace: Bool = false) { + func fetchNextPage(replace: Bool = false) { guard !isLoading else { return } var nextPage = -1 diff --git a/Sources/Screens/User/UserSelector.swift b/Sources/Screens/User/UserSelector.swift index a984b6c..18c1089 100644 --- a/Sources/Screens/User/UserSelector.swift +++ b/Sources/Screens/User/UserSelector.swift @@ -64,7 +64,7 @@ struct UserSelector: View { } } .refreshable { - userStore.fetchNext(replace: true) + userStore.fetchNextPage(replace: true) } .toolbar { ToolbarItem(placement: .topBarLeading) { @@ -107,7 +107,7 @@ struct UserSelector: View { } .onChange(of: userStore.query) { userStore.clear() - userStore.fetchNext() + userStore.fetchNextPage() } .sync($userStore.searchText, with: $searchText) .sync($userStore.showError, with: $showError) @@ -118,7 +118,7 @@ struct UserSelector: View { } private func fetchData() { - userStore.fetchNext(replace: true) + userStore.fetchNextPage(replace: true) } private func startTimers() { @@ -135,7 +135,7 @@ struct UserSelector: View { private func onListItemAppear(_ id: String) { if userStore.isEntityThreshold(id) { - userStore.fetchNext() + userStore.fetchNextPage() } } } diff --git a/Sources/Screens/User/UserStore.swift b/Sources/Screens/User/UserStore.swift index c260446..8ebf989 100644 --- a/Sources/Screens/User/UserStore.swift +++ b/Sources/Screens/User/UserStore.swift @@ -103,7 +103,7 @@ class UserStore: ObservableObject { return nil } - func fetchNext(replace: Bool = false) { + func fetchNextPage(replace: Bool = false) { guard !isLoading else { return } var nextPage = -1 diff --git a/Sources/Screens/Workspace/WorkspaceList.swift b/Sources/Screens/Workspace/WorkspaceList.swift index 4486969..3d06a3f 100644 --- a/Sources/Screens/Workspace/WorkspaceList.swift +++ b/Sources/Screens/Workspace/WorkspaceList.swift @@ -21,97 +21,82 @@ struct WorkspaceList: View { @State private var showAccount = false @State private var showCreate = false @State private var showOverview = false - @State private var showWorkspaceError = false - @State private var showAccountError = false - @State private var showInvitationError = false - @State private var showTaskError = false @State private var searchText = "" @State private var newWorkspace: VOWorkspace.Entity? var body: some View { NavigationStack { - if let entities = workspaceStore.entities { - Group { - if entities.count == 0 { - Text("There are no workspaces.") - } else { - List { - ForEach(entities, id: \.id) { workspace in - NavigationLink { - WorkspaceOverview(workspace, workspaceStore: workspaceStore) - } label: { - WorkspaceRow(workspace) - .onAppear { - onListItemAppear(workspace.id) - } + if isLoading { + ProgressView() + } else if let error { + VOErrorMessage(error) + } else { + if let entities = workspaceStore.entities { + Group { + if entities.count == 0 { + Text("There are no workspaces.") + } else { + List { + ForEach(entities, id: \.id) { workspace in + NavigationLink { + WorkspaceOverview(workspace, workspaceStore: workspaceStore) + } label: { + WorkspaceRow(workspace) + .onAppear { + onListItemAppear(workspace.id) + } + } } } - } - .searchable(text: $searchText) - .onChange(of: workspaceStore.searchText) { - workspaceStore.searchPublisher.send($1) + .searchable(text: $searchText) + .onChange(of: workspaceStore.searchText) { + workspaceStore.searchPublisher.send($1) + } } } - } - .navigationTitle("Home") - .refreshable { - workspaceStore.fetchNext(replace: true) - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - if UIDevice.current.userInterfaceIdiom == .phone { - accountButton - .padding(.trailing, VOMetrics.spacingXs) - } else { - accountButton - } + .navigationTitle("Home") + .refreshable { + workspaceStore.fetchNextPage(replace: true) } - ToolbarItem(placement: .topBarLeading) { - Button { - showCreate = true - } label: { - Image(systemName: "plus") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if UIDevice.current.userInterfaceIdiom == .phone { + accountButton + .padding(.trailing, VOMetrics.spacingXs) + } else { + accountButton + } + } + ToolbarItem(placement: .topBarLeading) { + Button { + showCreate = true + } label: { + Image(systemName: "plus") + } + } + ToolbarItem(placement: .topBarLeading) { + if workspaceStore.entitiesIsLoading, workspaceStore.entities != nil { + ProgressView() + } } } - ToolbarItem(placement: .topBarLeading) { - if workspaceStore.isLoading, workspaceStore.entities != nil { - ProgressView() + .sheet(isPresented: $showCreate) { + WorkspaceCreate(workspaceStore: workspaceStore) { newWorkspace in + self.newWorkspace = newWorkspace + showOverview = true } } - } - .sheet(isPresented: $showCreate) { - WorkspaceCreate(workspaceStore: workspaceStore) { newWorkspace in - self.newWorkspace = newWorkspace - showOverview = true + .sheet(isPresented: $showAccount) { + AccountOverview() } - } - .sheet(isPresented: $showAccount) { - AccountOverview() - } - .navigationDestination(isPresented: $showOverview) { - if let newWorkspace { - WorkspaceOverview(newWorkspace, workspaceStore: workspaceStore) + .navigationDestination(isPresented: $showOverview) { + if let newWorkspace { + WorkspaceOverview(newWorkspace, workspaceStore: workspaceStore) + } } } - } else { - ProgressView() } } - .voErrorAlert( - isPresented: $showWorkspaceError, - title: workspaceStore.errorTitle, - message: workspaceStore.errorMessage - ) - .voErrorAlert( - isPresented: $showAccountError, - title: accountStore.errorTitle, - message: accountStore.errorMessage - ) - .voErrorAlert( - isPresented: $showInvitationError, - title: invitationStore.errorTitle, - message: invitationStore.errorMessage - ) .onAppear { accountStore.tokenStore = tokenStore if let token = tokenStore.token { @@ -131,15 +116,24 @@ struct WorkspaceList: View { } .onChange(of: workspaceStore.query) { workspaceStore.clear() - workspaceStore.fetchNext() + workspaceStore.fetchNextPage() } .sync($workspaceStore.searchText, with: $searchText) - .sync($workspaceStore.showError, with: $showWorkspaceError) - .sync($accountStore.showError, with: $showAccountError) - .sync($invitationStore.showError, with: $showInvitationError) } - var accountButton: some View { + private var isLoading: Bool { + workspaceStore.entities == nil || + accountStore.identityUserIsLoading || + invitationStore.incomingCountIsLoading + } + + private var error: String? { + workspaceStore.entitiesError ?? + accountStore.identityUserError ?? + invitationStore.incomingCountError + } + + private var accountButton: some View { ZStack { Button { showAccount.toggle() @@ -173,8 +167,8 @@ struct WorkspaceList: View { } private func fetchData() { - workspaceStore.fetchNext(replace: true) - accountStore.fetchUser() + workspaceStore.fetchNextPage(replace: true) + accountStore.fetchIdentityUser() invitationStore.fetchIncomingCount() } @@ -198,7 +192,7 @@ struct WorkspaceList: View { private func onListItemAppear(_ id: String) { if workspaceStore.isEntityThreshold(id) { - workspaceStore.fetchNext() + workspaceStore.fetchNextPage() } } } diff --git a/Sources/Screens/Workspace/WorkspaceSettings.swift b/Sources/Screens/Workspace/WorkspaceSettings.swift index 8a259fb..95ea901 100644 --- a/Sources/Screens/Workspace/WorkspaceSettings.swift +++ b/Sources/Screens/Workspace/WorkspaceSettings.swift @@ -78,13 +78,7 @@ struct WorkspaceSettings: View { Button(role: .destructive) { showDeleteConfirmation = true } label: { - HStack { - Text("Delete Workspace") - if isDeleting { - Spacer() - ProgressView() - } - } + VOFormButtonLabel("Delete Workspace", isLoading: isDeleting) } .disabled(isDeleting) .confirmationDialog("Delete Workspace", isPresented: $showDeleteConfirmation) { diff --git a/Sources/Screens/Workspace/WorkspaceStore.swift b/Sources/Screens/Workspace/WorkspaceStore.swift index 2bbd552..fc3742b 100644 --- a/Sources/Screens/Workspace/WorkspaceStore.swift +++ b/Sources/Screens/Workspace/WorkspaceStore.swift @@ -14,16 +14,17 @@ import VoltaserveCore class WorkspaceStore: ObservableObject { @Published var entities: [VOWorkspace.Entity]? + @Published var entitiesIsLoading: Bool = false + @Published var entitiesError: String? @Published var current: VOWorkspace.Entity? @Published var root: VOFile.Entity? + @Published var rootIsLoading: Bool = false + @Published var rootError: String? @Published var storageUsage: VOStorage.Usage? + @Published var storageUsageIsLoading: Bool = false + @Published var storageUsageError: String? @Published var query: String? - @Published var showError = false - @Published var errorTitle: String? - @Published var errorMessage: String? - @Published var selection: String? @Published var searchText = "" - @Published var isLoading = false private var list: VOWorkspace.List? private var cancellables = Set() private var timer: Timer? @@ -75,8 +76,8 @@ class WorkspaceStore: ObservableObject { try await workspaceClient?.fetchList(.init(query: query, page: page, size: size)) } - func fetchNext(replace: Bool = false) { - guard !isLoading else { return } + func fetchNextPage(replace: Bool = false) { + guard !entitiesIsLoading else { return } var nextPage = -1 var list: VOWorkspace.List? @@ -99,7 +100,7 @@ class WorkspaceStore: ObservableObject { list = try await self.fetchList(page: nextPage) return true } before: { - self.isLoading = true + self.entitiesIsLoading = true } success: { self.list = list if let list { @@ -110,32 +111,30 @@ class WorkspaceStore: ObservableObject { } } } failure: { message in - self.errorTitle = "Error: Fetching Workspaces" - self.errorMessage = message - self.showError = true + self.entitiesError = message } anyways: { - self.isLoading = false + self.entitiesIsLoading = false } } - private func fetchFile(_ id: String) async throws -> VOFile.Entity? { - try await fileClient?.fetch(id) + private func fetchRoot() async throws -> VOFile.Entity? { + guard let current else { return nil } + return try await fileClient?.fetch(current.rootID) } func fetchRoot() { - guard let current else { return } - var root: VOFile.Entity? - withErrorHandling { - root = try await self.fetchFile(current.rootID) + root = try await self.fetchRoot() return true + } before: { + self.rootIsLoading = true } success: { self.root = root } failure: { message in - self.errorTitle = "Error: Fetching Workspace Root" - self.errorMessage = message - self.showError = true + self.rootError = message + } anyways: { + self.rootIsLoading = false } } @@ -145,17 +144,20 @@ class WorkspaceStore: ObservableObject { func fetchStorageUsage() { guard let current else { return } - var usage: VOStorage.Usage? + + var storageUsage: VOStorage.Usage? withErrorHandling { - usage = try await self.fetchStorageUsage(current.id) + storageUsage = try await self.fetchStorageUsage(current.id) return true + } before: { + self.storageUsageIsLoading = true } success: { - self.storageUsage = usage + self.storageUsage = storageUsage } failure: { message in - self.errorTitle = "Error: Fetching Storage Usage" - self.errorMessage = message - self.showError = true + self.storageUsageError = message + } anyways: { + self.storageUsageIsLoading = false } } @@ -264,7 +266,7 @@ class WorkspaceStore: ObservableObject { } } Task { - let root = try await self.fetchFile(current.rootID) + let root = try await self.fetchRoot() if let root { DispatchQueue.main.async { self.root = root diff --git a/Voltaserve.xcodeproj/project.pbxproj b/Voltaserve.xcodeproj/project.pbxproj index 498dc3f..cd708f9 100644 --- a/Voltaserve.xcodeproj/project.pbxproj +++ b/Voltaserve.xcodeproj/project.pbxproj @@ -211,6 +211,7 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 644C38B42C8F3744008E8F0F /* Helpers */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Helpers; sourceTree = ""; }; 644C38B72C8F3744008E8F0F /* Infrastructure */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (644C38DA2C8F3744008E8F0F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Infrastructure; sourceTree = ""; }; + 64C0F65F2CF26AE8007049E7 /* Protocol */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Protocol; sourceTree = ""; }; 64DA01D12C98766E0063A4CB /* Design */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Design; sourceTree = ""; }; 64DA46312CF1A7AA00D57366 /* Library */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Library; sourceTree = ""; }; 64E4C9B52C92495000395C9D /* Screens */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (64E4CA1B2C92496100395C9D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Screens; sourceTree = ""; }; @@ -256,6 +257,7 @@ 649066E02C5E35AD00BA3CCD /* Sources */ = { isa = PBXGroup; children = ( + 64C0F65F2CF26AE8007049E7 /* Protocol */, 64DA46312CF1A7AA00D57366 /* Library */, 64E4C9B52C92495000395C9D /* Screens */, 64DA01D12C98766E0063A4CB /* Design */, @@ -304,6 +306,7 @@ ); fileSystemSynchronizedGroups = ( 644C38B42C8F3744008E8F0F /* Helpers */, + 64C0F65F2CF26AE8007049E7 /* Protocol */, 64DA01D12C98766E0063A4CB /* Design */, 64DA46312CF1A7AA00D57366 /* Library */, );