diff --git a/Sources/Protocol/ErrorPresentable.swift b/Sources/Protocols/ErrorPresentable.swift similarity index 100% rename from Sources/Protocol/ErrorPresentable.swift rename to Sources/Protocols/ErrorPresentable.swift diff --git a/Sources/Protocol/FormValidatable.swift b/Sources/Protocols/FormValidatable.swift similarity index 100% rename from Sources/Protocol/FormValidatable.swift rename to Sources/Protocols/FormValidatable.swift diff --git a/Sources/Protocols/ListItemScrollable.swift b/Sources/Protocols/ListItemScrollable.swift new file mode 100644 index 0000000..430dc24 --- /dev/null +++ b/Sources/Protocols/ListItemScrollable.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 ListItemScrollable { + func onListItemAppear(_ id: String) +} diff --git a/Sources/Protocol/LoadStateProvider.swift b/Sources/Protocols/LoadStateProvider.swift similarity index 100% rename from Sources/Protocol/LoadStateProvider.swift rename to Sources/Protocols/LoadStateProvider.swift diff --git a/Sources/Protocol/TimerLifecycle.swift b/Sources/Protocols/TimerLifecycle.swift similarity index 100% rename from Sources/Protocol/TimerLifecycle.swift rename to Sources/Protocols/TimerLifecycle.swift diff --git a/Sources/Protocol/TokenDistributing.swift b/Sources/Protocols/TokenDistributing.swift similarity index 100% rename from Sources/Protocol/TokenDistributing.swift rename to Sources/Protocols/TokenDistributing.swift diff --git a/Sources/Protocol/ViewDataProvider.swift b/Sources/Protocols/ViewDataProvider.swift similarity index 100% rename from Sources/Protocol/ViewDataProvider.swift rename to Sources/Protocols/ViewDataProvider.swift diff --git a/Sources/Screens/Browser/BrowserList.swift b/Sources/Screens/Browser/BrowserList.swift index 9da5010..ca23460 100644 --- a/Sources/Screens/Browser/BrowserList.swift +++ b/Sources/Screens/Browser/BrowserList.swift @@ -12,26 +12,24 @@ import Combine import SwiftUI import VoltaserveCore -struct BrowserList: View { +struct BrowserList: View, LoadStateProvider, ViewDataProvider, TimerLifecycle, TokenDistributing, ListItemScrollable { @EnvironmentObject private var tokenStore: TokenStore @ObservedObject private var workspaceStore: WorkspaceStore @StateObject private var browserStore = BrowserStore() @State private var tappedItem: VOFile.Entity? - @State private var showError = false - @State private var searchText = "" - private let fileID: String + private let folderID: String private let confirmLabelText: String? private let onCompletion: ((String) -> Void)? private let onDismiss: (() -> Void)? init( - _ fileID: String, + _ folderID: String, workspaceStore: WorkspaceStore, confirmLabelText: String?, onCompletion: ((String) -> Void)? = nil, onDismiss: (() -> Void)? = nil ) { - self.fileID = fileID + self.folderID = folderID self.workspaceStore = workspaceStore self.confirmLabelText = confirmLabelText self.onCompletion = onCompletion @@ -40,58 +38,57 @@ struct BrowserList: View { var body: some View { VStack { - if let entities = browserStore.entities { - Group { - if entities.count == 0 { - Text("There are no items.") - } else { - List { - ForEach(entities, id: \.id) { file in - NavigationLink { - BrowserList( - file.id, - workspaceStore: workspaceStore, - confirmLabelText: confirmLabelText, - onCompletion: onCompletion - ) - .navigationTitle(file.name) - } label: { - FileRow(file) - } - .onAppear { - onListItemAppear(file.id) + if isLoading { + ProgressView() + } else if let error { + VOErrorMessage(error) + } else { + if let entities = browserStore.entities { + Group { + if entities.count == 0 { + Text("There are no items.") + } else { + List { + ForEach(entities, id: \.id) { file in + NavigationLink { + BrowserList( + file.id, + workspaceStore: workspaceStore, + confirmLabelText: confirmLabelText, + onCompletion: onCompletion + ) + .navigationTitle(file.name) + } label: { + FileRow(file) + } + .onAppear { + onListItemAppear(file.id) + } } } + .listStyle(.inset) + .searchable(text: $browserStore.searchText) + .onChange(of: browserStore.searchText) { + browserStore.searchPublisher.send($1) + } + .navigationDestination(item: $tappedItem) { + Viewer($0) + } } - .listStyle(.inset) - .searchable(text: $searchText) - .onChange(of: browserStore.searchText) { - browserStore.searchPublisher.send($1) - } - .navigationDestination(item: $tappedItem) { - Viewer($0) - } - .voErrorAlert( - isPresented: $showError, - title: browserStore.errorTitle, - message: browserStore.errorMessage - ) + } + .refreshable { + browserStore.fetchNextPage(replace: true) } } - .refreshable { - browserStore.fetchNextPage(replace: true) - } - } else { - ProgressView() } } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button(confirmLabelText ?? "Done") { - onCompletion?(fileID) + onCompletion?(folderID) } } - if let workspace = workspaceStore.current, fileID == workspace.rootID { + if let workspace = workspaceStore.current, folderID == workspace.rootID { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { onDismiss?() @@ -99,15 +96,15 @@ struct BrowserList: View { } } ToolbarItem(placement: .topBarLeading) { - if browserStore.isLoading, browserStore.entities != nil { + if browserStore.entitiesIsLoading { ProgressView() } } } .onAppear { - browserStore.fileID = fileID + browserStore.folderID = folderID if let token = tokenStore.token { - assignTokensToStores(token) + assignTokenToStores(token) startTimers() onAppearOrChange() } @@ -117,7 +114,7 @@ struct BrowserList: View { } .onChange(of: tokenStore.token) { _, newToken in if let newToken { - assignTokensToStores(newToken) + assignTokenToStores(newToken) onAppearOrChange() } } @@ -125,32 +122,48 @@ struct BrowserList: View { browserStore.clear() browserStore.fetchNextPage() } - .sync($browserStore.searchText, with: $searchText) - .sync($browserStore.showError, with: $showError) } - private func onAppearOrChange() { + // MARK: - LoadStateProvider + + var isLoading: Bool { + workspaceStore.entities == nil && browserStore.folderIsLoading + } + + var error: String? { + workspaceStore.entitiesError ?? browserStore.folderError + } + + // MARK: - ViewDataProvider + + func onAppearOrChange() { fetchData() } - private func fetchData() { - browserStore.fetch() + func fetchData() { + browserStore.fetchFolder() browserStore.fetchNextPage(replace: true) } - private func startTimers() { + // MARK: - TimerLifecycle + + func startTimers() { browserStore.startTimer() } - private func stopTimers() { + func stopTimers() { browserStore.stopTimer() } - private func assignTokensToStores(_ token: VOToken.Value) { + // MARK: - TokenDistributing + + func assignTokenToStores(_ token: VOToken.Value) { browserStore.token = token } - private func onListItemAppear(_ id: String) { + // MARK: - ListItemScrollable + + func onListItemAppear(_ id: String) { if browserStore.isEntityThreshold(id) { browserStore.fetchNextPage() } diff --git a/Sources/Screens/Browser/BrowserStore.swift b/Sources/Screens/Browser/BrowserStore.swift index 44d6ed5..2042a31 100644 --- a/Sources/Screens/Browser/BrowserStore.swift +++ b/Sources/Screens/Browser/BrowserStore.swift @@ -14,18 +14,18 @@ import VoltaserveCore class BrowserStore: ObservableObject { @Published var entities: [VOFile.Entity]? - @Published var current: VOFile.Entity? + @Published var entitiesIsLoading: Bool = false + @Published var entitiesError: String? + @Published var folder: VOFile.Entity? + @Published var folderIsLoading: Bool = false + @Published var folderError: String? @Published var query: VOFile.Query? - @Published var showError = false - @Published var errorTitle: String? - @Published var errorMessage: String? @Published var searchText = "" - @Published var isLoading = false private var list: VOFile.List? private var cancellables = Set() private var timer: Timer? private var fileClient: VOFile? - var fileID: String? + var folderID: String? let searchPublisher = PassthroughSubject() var token: VOToken.Value? { @@ -51,23 +51,24 @@ class BrowserStore: ObservableObject { // MARK: - Fetch - private func fetch(_ id: String) async throws -> VOFile.Entity? { - try await fileClient?.fetch(id) + private func fetchFolder() async throws -> VOFile.Entity? { + guard let folderID else { return nil } + return try await fileClient?.fetch(folderID) } - func fetch() { - guard let fileID else { return } - var file: VOFile.Entity? - + func fetchFolder() { + var folder: VOFile.Entity? withErrorHandling { - file = try await self.fetch(fileID) + folder = try await self.fetchFolder() return true + } before: { + self.folderIsLoading = true } success: { - self.current = file + self.folder = folder } failure: { message in - self.errorTitle = "Error: Fetching File" - self.errorMessage = message - self.showError = true + self.folderError = message + } anyways: { + self.folderIsLoading = false } } @@ -80,15 +81,15 @@ class BrowserStore: ObservableObject { } func fetchNextPage(replace: Bool = false) { - guard let fileID else { return } - guard !isLoading else { return } + guard let folderID else { return } + guard !entitiesIsLoading else { return } var nextPage = -1 var list: VOFile.List? withErrorHandling { if let list = self.list { - let probe = try await self.fetchProbe(fileID, size: Constants.pageSize) + let probe = try await self.fetchProbe(folderID, size: Constants.pageSize) if let probe { self.list = .init( data: list.data, @@ -102,10 +103,10 @@ class BrowserStore: ObservableObject { } if !self.hasNextPage() { return false } nextPage = self.nextPage() - list = try await self.fetchList(fileID, page: nextPage) + list = try await self.fetchList(folderID, page: nextPage) return true } before: { - self.isLoading = true + self.entitiesIsLoading = true } success: { self.list = list if let list { @@ -116,11 +117,9 @@ class BrowserStore: ObservableObject { } } } failure: { message in - self.errorTitle = "Error: Fetching Files" - self.errorMessage = message - self.showError = true + self.entitiesError = message } anyways: { - self.isLoading = false + self.entitiesIsLoading = false } } @@ -176,7 +175,7 @@ class BrowserStore: ObservableObject { func startTimer() { guard timer == nil else { return } timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in - if let current = self.current { + if let current = self.folder { Task { var size = Constants.pageSize if let list = self.list { @@ -190,12 +189,12 @@ class BrowserStore: ObservableObject { } } } - if let current = self.current { + if let current = self.folder { Task { - let file = try await self.fetch(current.id) + let file = try await self.fetchFolder() if let file { DispatchQueue.main.async { - self.current = file + self.folder = file } } } diff --git a/Voltaserve.xcodeproj/project.pbxproj b/Voltaserve.xcodeproj/project.pbxproj index cd708f9..2ac886c 100644 --- a/Voltaserve.xcodeproj/project.pbxproj +++ b/Voltaserve.xcodeproj/project.pbxproj @@ -211,7 +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 = ""; }; + 64C0F65F2CF26AE8007049E7 /* Protocols */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Protocols; 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 = ""; }; @@ -257,7 +257,7 @@ 649066E02C5E35AD00BA3CCD /* Sources */ = { isa = PBXGroup; children = ( - 64C0F65F2CF26AE8007049E7 /* Protocol */, + 64C0F65F2CF26AE8007049E7 /* Protocols */, 64DA46312CF1A7AA00D57366 /* Library */, 64E4C9B52C92495000395C9D /* Screens */, 64DA01D12C98766E0063A4CB /* Design */, @@ -306,7 +306,7 @@ ); fileSystemSynchronizedGroups = ( 644C38B42C8F3744008E8F0F /* Helpers */, - 64C0F65F2CF26AE8007049E7 /* Protocol */, + 64C0F65F2CF26AE8007049E7 /* Protocols */, 64DA01D12C98766E0063A4CB /* Design */, 64DA46312CF1A7AA00D57366 /* Library */, );