diff --git a/BeReal/UI/Views/ContentView.swift b/BeReal/UI/Views/ContentView.swift index aa429f0..a3b8a2c 100644 --- a/BeReal/UI/Views/ContentView.swift +++ b/BeReal/UI/Views/ContentView.swift @@ -12,6 +12,7 @@ import Login import KeychainStorage import Profile import SwiftUI +import UseCases struct ContentView: View { @@ -24,49 +25,94 @@ struct ContentView: View { @State private var user: User? @State private var showSheet: SheetView? + // MARK: Properties + + private let getUser: GetUserUseCase = .init() + // MARK: Body var body: some View { - NavigationView { - BrowseView { - // ... - } uploadFile: { - // ... - } showProfile: { - showSheet = .profile - } - } - .onAppear { - shouldShowLogin() - } - .onChange(of: account) { _ in - shouldShowLogin() + Container(user: user) { + // TODO: create a new folder + } uploadFile: { + // TODO: upload a new file + } showProfile: { + showSheet = .profile + } login: { + showSheet = .login } .sheet(item: $showSheet) { sheet in switch sheet { case .login: LoginView { - user = $1 account = $0 + user = $1 } case .profile: ProfileView(user: user) { - user = nil account = nil + user = nil } } } + .task(id: account) { + await loadUserOrLogin() + } } } +// MARK: - Views + +private extension ContentView { + struct Container: View { + + // MARK: Properties + + let user: User? + let createFolder: ActionClosure + let uploadFile: ActionClosure + let showProfile: ActionClosure + let login: ActionClosure + + // MARK: Body + + var body: some View { + if let user { + NavigationView { + BrowseView( + folder: .init( + id: user.rootFolder.id, + name: user.rootFolder.name + ), + createFolder: createFolder, + uploadFile: uploadFile, + showProfile: showProfile, + login: login + ) + } + } else { + EmptyView() + } + } + + } +} + // MARK: - Helpers private extension ContentView { - func shouldShowLogin() { - showSheet = account == nil - ? .login - : nil + func loadUserOrLogin() async { + guard let account else { + showSheet = .login + return + } + + showSheet = nil + user = try? await getUser( + username: account.username, + password: account.password + ) } } diff --git a/Libraries/Package.swift b/Libraries/Package.swift index f4e096b..802ba69 100644 --- a/Libraries/Package.swift +++ b/Libraries/Package.swift @@ -12,7 +12,8 @@ let package = Package( "APIService", "DataModels", "Dependencies", - "KeychainStorage" + "KeychainStorage", + "UseCases" ] ), ], @@ -39,6 +40,16 @@ let package = Package( "KeychainAccess" ] ), + .target( + name: "UseCases", + dependencies: [ + "Cores", + "APIService", + "DataModels", + "Dependencies", + "KeychainStorage" + ] + ), .testTarget( name: "APIServiceTests", dependencies: ["APIService"] diff --git a/Modules/Sources/Login/Logic/Use Cases/GetUserUseCase.swift b/Libraries/Sources/UseCases/Users/GetUserUseCase.swift similarity index 50% rename from Modules/Sources/Login/Logic/Use Cases/GetUserUseCase.swift rename to Libraries/Sources/UseCases/Users/GetUserUseCase.swift index f49fa92..e06aeae 100644 --- a/Modules/Sources/Login/Logic/Use Cases/GetUserUseCase.swift +++ b/Libraries/Sources/UseCases/Users/GetUserUseCase.swift @@ -7,25 +7,26 @@ // import APIService +import DataModels import DependencyInjection import Dependencies -struct GetUserUseCase { +public struct GetUserUseCase { // MARK: Dependencies @Dependency(\.apiService) private var apiService - // MARK: Properties + // MARK: Initialisers - let authenticated: AuthenticatedClosure + public init() {} // MARK: Functions - func callAsFunction( + public func callAsFunction( username: String, password: String - ) async throws { + ) async throws -> User { let me = try await apiService.getUser( credentials: .init( username: username, @@ -33,21 +34,15 @@ struct GetUserUseCase { ) ) - authenticated( - .init( - username: username, - password: password + return .init( + profile: .init( + firstName: me.firstName, + lastName: me.lastName ), - .init( - profile: .init( - firstName: me.firstName, - lastName: me.lastName - ), - rootFolder: .init( - id: me.rootItem.id, - name: me.rootItem.name, - lastModifiedAt: me.rootItem.lastModifiedAt - ) + rootFolder: .init( + id: me.rootItem.id, + name: me.rootItem.name, + lastModifiedAt: me.rootItem.lastModifiedAt ) ) } diff --git a/Modules/Sources/Browse/Logic/Models/Document.swift b/Modules/Sources/Browse/Logic/Models/Document.swift new file mode 100644 index 0000000..46df5eb --- /dev/null +++ b/Modules/Sources/Browse/Logic/Models/Document.swift @@ -0,0 +1,41 @@ +// +// Document.swift +// Browse +// +// Created by Javier Cicchelli on 13/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import Foundation + +struct Document { + + // MARK: Properties + + public let id: String + public let name: String + public let contentType: String + public let size: Int + public let lastModifiedAt: Date + + // MARK: Initialisers + + public init( + id: String, + name: String, + contentType: String, + size: Int, + lastModifiedAt: Date + ) { + self.id = id + self.name = name + self.contentType = contentType + self.size = size + self.lastModifiedAt = lastModifiedAt + } + +} + +// MARK: - FileSystemIdIdentifiable + +extension Document: FileSystemItemIdentifiable {} diff --git a/Modules/Sources/Browse/Logic/Models/Folder.swift b/Modules/Sources/Browse/Logic/Models/Folder.swift new file mode 100644 index 0000000..b089e36 --- /dev/null +++ b/Modules/Sources/Browse/Logic/Models/Folder.swift @@ -0,0 +1,34 @@ +// +// Folder.swift +// Browse +// +// Created by Javier Cicchelli on 13/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +public struct Folder { + + // MARK: Properties + + public let id: String + public let name: String + + // MARK: Initialisers + + public init( + id: String, + name: String + ) { + self.id = id + self.name = name + } + +} + +// MARK: - FileSystemIdIdentifiable + +extension Folder: FileSystemItemIdentifiable {} + +// MARK: - Equatable + +extension Folder: Equatable {} diff --git a/Modules/Sources/Browse/Logic/Protocols/FileSystemItem.swift b/Modules/Sources/Browse/Logic/Protocols/FileSystemItem.swift new file mode 100644 index 0000000..d4f4d04 --- /dev/null +++ b/Modules/Sources/Browse/Logic/Protocols/FileSystemItem.swift @@ -0,0 +1,16 @@ +// +// FileSystemItem.swift +// Browse +// +// Created by Javier Cicchelli on 13/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +protocol FileSystemItem { + var id: String { get } + var name: String { get } +} + +// MARK: - Type aliases + +typealias FileSystemItemIdentifiable = FileSystemItem & Identifiable & Hashable diff --git a/Modules/Sources/Browse/Logic/Use Cases/GetItemsUseCase.swift b/Modules/Sources/Browse/Logic/Use Cases/GetItemsUseCase.swift new file mode 100644 index 0000000..bbd219f --- /dev/null +++ b/Modules/Sources/Browse/Logic/Use Cases/GetItemsUseCase.swift @@ -0,0 +1,53 @@ +// +// GetItemsUseCase.swift +// Browse +// +// Created by Javier Cicchelli on 13/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import APIService +import DependencyInjection +import Dependencies + +struct GetItemsUseCase { + + // MARK: Dependencies + + @Dependency(\.apiService) private var apiService + + // MARK: Functions + + func callAsFunction( + id: String, + username: String, + password: String + ) async throws -> [any FileSystemItemIdentifiable] { + let items = try await apiService.getItems( + id: id, + credentials: .init( + username: username, + password: password + ) + ) + + return items + .compactMap { item -> any FileSystemItemIdentifiable in + if item.isDirectory { + return Folder( + id: item.id, + name: item.name + ) + } else { + return Document( + id: item.id, + name: item.name, + contentType: item.contentType ?? "-", + size: item.size ?? 0, + lastModifiedAt: item.lastModifiedAt + ) + } + } + } + +} diff --git a/Modules/Sources/Browse/Resources/en.lproj/Localizable.strings b/Modules/Sources/Browse/Resources/en.lproj/Localizable.strings index 2724f95..d6f8df1 100644 --- a/Modules/Sources/Browse/Resources/en.lproj/Localizable.strings +++ b/Modules/Sources/Browse/Resources/en.lproj/Localizable.strings @@ -6,9 +6,28 @@ Copyright © 2022 Röck+Cöde. All rights reserved. */ +// LoadingView + +"loading.loading_data.text" = "Loading data\nfrom the API..."; + +// MessageView + +"message.type_no_credentials.text.first" = "No user credentials have been found in your device"; +"message.type_no_credentials.text.second" = "Please login again with your credentials to load this data."; +"message.type_no_credentials.button.text" = "Log in"; +"message.type_empty.text.first" = "No data has been found for this folder"; +"message.type_empty.text.second" = "Please populate this folder by uploading some file from your device."; +"message.type_empty.button.text" = "Upload a file"; +"message.type_error.text.first" = "An error occurred while loading this data"; +"message.type_error.text.second" = "Please try loading this data again at a later time."; +"message.type_error.button.text" = "Try again"; + +// BrowseView + "browse.toolbar_item.menu.add_actions.text" = "Add file and/or folder"; "browse.toolbar_item.button.add_folder.text" = "Create a new folder"; "browse.toolbar_item.button.add_file.text" = "Upload a file"; "browse.toolbar_item.button.show_profile.text" = "Show profile"; + "browse.swipe_action.delete_item.text" = "Delete item"; "browse.swipe_action.download_item.text" = "Download item"; diff --git a/Modules/Sources/Browse/UI/Components/DocumentItem.swift b/Modules/Sources/Browse/UI/Components/DocumentItem.swift index 11ee561..7a6ae70 100644 --- a/Modules/Sources/Browse/UI/Components/DocumentItem.swift +++ b/Modules/Sources/Browse/UI/Components/DocumentItem.swift @@ -6,42 +6,86 @@ // Copyright © 2022 Röck+Cöde. All rights reserved. // +import DataModels import SwiftUI struct DocumentItem: View { // MARK: Properties - let name: String - let lastModified: String - let fileSize: String + let item: FileSystemItem + let select: ActionClosure + let download: ActionClosure + let delete: ActionClosure // MARK: Body var body: some View { - HStack(spacing: 16) { - Image.document - .icon(size: 32) - .foregroundColor(.red) - - VStack { - Text(name) - .itemName() + Button { + select() + } label: { + HStack(spacing: 16) { + Image.document + .icon(size: 32) + .foregroundColor(.red) - HStack { - Text(lastModified) + VStack(spacing: 8) { + Text(item.name) + .itemName() - Spacer() - - Text(fileSize) + HStack { + Text("lastModified") + + Spacer() + + Text("fileSize") + } + .font(.subheadline) + .foregroundColor(.secondary) } - .font(.subheadline) - .foregroundColor(.secondary) } + .padding(.vertical, 4) + } + .swipeActions( + edge: .trailing, + allowsFullSwipe: true + ) { + Button { + delete() + } label: { + Label { + Text( + "browse.swipe_action.delete_item.text", + bundle: .module + ) + } icon: { + Image.trash + } + } + .tint(.red) + + Button { + download() + } label: { + Label { + Text( + "browse.swipe_action.download_item.text", + bundle: .module + ) + } icon: { + Image.download + } + } + .tint(.orange) } - .padding(.vertical, 8) } + +} +// MARK: - Helpers + +private extension DocumentItem { + var document: Document? { item as? Document } } // MARK: - Image+Constants @@ -54,18 +98,34 @@ private extension Image { struct DocumentItem_Previews: PreviewProvider { static var previews: some View { - DocumentItem( + DocumentItem(item: Document( + id: "1234567890", name: "Some document name goes in here...", - lastModified: "Some few hours ago", - fileSize: "23,5 Mbytes" - ) + contentType: "some content type", + size: .random(in: 1 ... 100), + lastModifiedAt: .now + )) { + // select closure. + } download: { + // download closure. + } delete: { + // delete closure. + } .previewDisplayName("Document item") - DocumentItem( + DocumentItem(item: Document( + id: "1234567890", name: "Some very, extremely long document name goes in here...", - lastModified: "Yesterday", - fileSize: "235,6 Kbytes" - ) + contentType: "some content type", + size: .random(in: 1 ... 100), + lastModifiedAt: .now + )) { + // select closure. + } download: { + // download closure. + } delete: { + // delete closure. + } .previewDisplayName("Document item with long name") } } diff --git a/Modules/Sources/Browse/UI/Components/FolderItem.swift b/Modules/Sources/Browse/UI/Components/FolderItem.swift index fd89e77..40733f4 100644 --- a/Modules/Sources/Browse/UI/Components/FolderItem.swift +++ b/Modules/Sources/Browse/UI/Components/FolderItem.swift @@ -6,31 +6,56 @@ // Copyright © 2022 Röck+Cöde. All rights reserved. // +import DataModels import SwiftUI struct FolderItem: View { // MARK: Properties - let name: String + let item: FileSystemItem + let select: ActionClosure + let delete: ActionClosure // MARK: Body var body: some View { - HStack(spacing: 16) { - Image.folder - .icon(size: 32) - .foregroundColor(.red) - - Text(name) - .itemName() - - Image.chevronRight - .icon(size: 16) - .foregroundColor(.secondary) - .font(.headline) + Button { + select() + } label: { + HStack(spacing: 16) { + Image.folder + .icon(size: 32) + .foregroundColor(.red) + + Text(item.name) + .itemName() + + Image.chevronRight + .icon(size: 16) + .foregroundColor(.secondary) + .font(.headline) + } + .padding(.vertical, 8) + } + .swipeActions( + edge: .trailing, + allowsFullSwipe: true + ) { + Button { + delete() + } label: { + Label { + Text( + "browse.swipe_action.delete_item.text", + bundle: .module + ) + } icon: { + Image.trash + } + } + .tint(.red) } - .padding(.vertical, 8) } } @@ -46,10 +71,24 @@ private extension Image { struct BrowseItem_Previews: PreviewProvider { static var previews: some View { - FolderItem(name: "Some folder name goes in here...") - .previewDisplayName("Folder item") + FolderItem(item: Folder( + id: "1234567890", + name: "Some folder name goes in here..." + )) { + // select closure. + } delete: { + // delete closure. + } + .previewDisplayName("Folder item") - FolderItem(name: "Some very, extremely long folder name goes in here...") - .previewDisplayName("Folder item with long name") + FolderItem(item: Folder( + id: "1234567890", + name: "Some very, extremely long folder name goes in here..." + )) { + // select closure. + } delete: { + // delete closure. + } + .previewDisplayName("Folder item with long name") } } diff --git a/Modules/Sources/Browse/UI/Components/LoadingView.swift b/Modules/Sources/Browse/UI/Components/LoadingView.swift new file mode 100644 index 0000000..d57ec8d --- /dev/null +++ b/Modules/Sources/Browse/UI/Components/LoadingView.swift @@ -0,0 +1,35 @@ +// +// LoadingView.swift +// Browse +// +// Created by Javier Cicchelli on 15/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import SwiftUI + +struct LoadingView: View { + var body: some View { + VStack(spacing: 24) { + ProgressView() + .controlSize(.large) + .tint(.red) + + Text( + "loading.loading_data.text", + bundle: .module + ) + .font(.body) + .fontWeight(.semibold) + } + .ignoresSafeArea() + } +} + +// MARK: - Previews + +struct LoadingView_Previews: PreviewProvider { + static var previews: some View { + LoadingView() + } +} diff --git a/Modules/Sources/Browse/UI/Components/MessageView.swift b/Modules/Sources/Browse/UI/Components/MessageView.swift new file mode 100644 index 0000000..eaa6356 --- /dev/null +++ b/Modules/Sources/Browse/UI/Components/MessageView.swift @@ -0,0 +1,134 @@ +// +// MessageView.swift +// Browse +// +// Created by Javier Cicchelli on 15/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import DataModels +import SwiftUI + +struct MessageView: View { + + // MARK: Properties + + let type: MessageType + let action: ActionClosure + + // MARK: Body + + var body: some View { + VStack(spacing: 72) { + Image.warning + .resizable() + .scaledToFit() + .frame(width: 120, height: 120) + + VStack(spacing: 48) { + Text( + type.firstText, + bundle: .module + ) + .font(.title) + .fontWeight(.semibold) + + Text( + type.secondText, + bundle: .module + ) + .font(.title2) + .fontWeight(.regular) + } + .multilineTextAlignment(.center) + + Button(action: action) { + Text( + type.button, + bundle: .module + ) + .font(.body) + .foregroundColor(.primary) + .frame(maxWidth: .infinity) + } + .tint(.red) + .buttonStyle(.borderedProminent) + .buttonBorderShape(.roundedRectangle(radius: 8)) + .controlSize(.large) + } + .padding(.horizontal, 48) + .ignoresSafeArea() + } +} + +// MARK: - Enumerations + +extension MessageView { + enum MessageType { + case noCredentials + case empty + case error + } +} + +private extension MessageView.MessageType { + var firstText: LocalizedStringKey { + switch self { + case .noCredentials: + return "message.type_no_credentials.text.first" + case .empty: + return "message.type_empty.text.first" + case .error: + return "message.type_error.text.first" + } + } + + var secondText: LocalizedStringKey { + switch self { + case .noCredentials: + return "message.type_no_credentials.text.second" + case .empty: + return "message.type_empty.text.second" + case .error: + return "message.type_error.text.second" + } + } + + var button: LocalizedStringKey { + switch self { + case .noCredentials: + return "message.type_no_credentials.button.text" + case .empty: + return "message.type_empty.button.text" + case .error: + return "message.type_error.button.text" + } + } +} + +// MARK: - Image+Constants + +private extension Image { + static let warning = Image(systemName: "exclamationmark.circle.fill") +} + +// MARK: - Previews + +struct MessageView_Previews: PreviewProvider { + static var previews: some View { + MessageView(type: .noCredentials) { + // action closure. + } + .previewDisplayName("View of type no credentials") + + MessageView(type: .empty) { + // action closure. + } + .previewDisplayName("View of type empty") + + MessageView(type: .error) { + // action closure. + } + .previewDisplayName("View of type error") + } +} diff --git a/Modules/Sources/Browse/UI/Enumerations/Stack.swift b/Modules/Sources/Browse/UI/Enumerations/Stack.swift new file mode 100644 index 0000000..d43cd4a --- /dev/null +++ b/Modules/Sources/Browse/UI/Enumerations/Stack.swift @@ -0,0 +1,35 @@ +// +// Stack.swift +// Browse +// +// Created by Javier Cicchelli on 15/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +enum Stack { + case browse(Folder) +} + +// MARK: - Computed + +extension Stack { + var tag: String { + if case .browse(let folder) = self { + return folder.id + } else { + return .Constants.noId + } + } +} + +// MARK: - Hashable + +extension Stack: Hashable {} + +// MARK: - String+Constants + +private extension String { + enum Constants { + static let noId = "-" + } +} diff --git a/Modules/Sources/Browse/UI/Extensions/Image+Constants.swift b/Modules/Sources/Browse/UI/Extensions/Image+Constants.swift new file mode 100644 index 0000000..adebaa7 --- /dev/null +++ b/Modules/Sources/Browse/UI/Extensions/Image+Constants.swift @@ -0,0 +1,14 @@ +// +// Image+Constants.swift +// Browse +// +// Created by Javier Cicchelli on 14/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import SwiftUI + +extension Image { + static let trash = Image(systemName: "trash") + static let download = Image(systemName: "arrow.down.doc") +} diff --git a/Modules/Sources/Browse/UI/Extensions/View+ViewModifiers.swift b/Modules/Sources/Browse/UI/Extensions/View+ViewModifiers.swift new file mode 100644 index 0000000..7b199c9 --- /dev/null +++ b/Modules/Sources/Browse/UI/Extensions/View+ViewModifiers.swift @@ -0,0 +1,23 @@ +// +// View+ViewModifiers.swift +// Browse +// +// Created by Javier Cicchelli on 14/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import SwiftUI + +extension View { + func navigate( + to destination: some View, + tagged tag: Stack, + in stack: Binding + ) -> some View { + modifier(StackNavigationViewModifier( + tag: tag, + stack: stack, + destination: { destination } + )) + } +} diff --git a/Modules/Sources/Browse/UI/View Modifiers/StackNavigationViewModifier.swift b/Modules/Sources/Browse/UI/View Modifiers/StackNavigationViewModifier.swift new file mode 100644 index 0000000..72c3c12 --- /dev/null +++ b/Modules/Sources/Browse/UI/View Modifiers/StackNavigationViewModifier.swift @@ -0,0 +1,36 @@ +// +// StackNavigationViewModifiers.swift +// Browse +// +// Created by Javier Cicchelli on 14/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import SwiftUI + +struct StackNavigationViewModifier: ViewModifier { + + // MARK: Properties + + let tag: Stack + + @Binding var stack: Stack? + + @ViewBuilder var destination: Destination + + // MARK: Functions + + func body(content: Content) -> some View { + content + .background( + NavigationLink( + destination: destination, + tag: tag, + selection: $stack + ) { + EmptyView() + } + ) + } + +} diff --git a/Modules/Sources/Browse/UI/Views/BrowseView.swift b/Modules/Sources/Browse/UI/Views/BrowseView.swift index 2ee78a4..030c0ed 100644 --- a/Modules/Sources/Browse/UI/Views/BrowseView.swift +++ b/Modules/Sources/Browse/UI/Views/BrowseView.swift @@ -7,134 +7,182 @@ // import DataModels +import KeychainStorage import SwiftUI public struct BrowseView: View { + // MARK: Storages + + @KeychainStorage(key: .KeychainStorage.account) private var account: Account? + + // MARK: States + + @State private var status: ViewStatus = .loading + @State private var items: [any FileSystemItemIdentifiable] = [] + @State private var stack: Stack? + // MARK: Properties + private let folder: Folder private let createFolder: ActionClosure private let uploadFile: ActionClosure private let showProfile: ActionClosure + private let login: ActionClosure + + private let getItems: GetItemsUseCase = .init() // MARK: Initialisers public init( + folder: Folder, createFolder: @escaping ActionClosure, uploadFile: @escaping ActionClosure, - showProfile: @escaping ActionClosure + showProfile: @escaping ActionClosure, + login: @escaping ActionClosure ) { + self.folder = folder self.createFolder = createFolder self.uploadFile = uploadFile self.showProfile = showProfile + self.login = login } // MARK: Body public var body: some View { - List { - Group { - Group { - FolderItem(name: "Some folder #1 name") - FolderItem(name: "Some folder #2 name") - FolderItem(name: "Some folder #3 name") - FolderItem(name: "Some folder #4 name") - FolderItem(name: "Some folder #5 name") - FolderItem(name: "Some folder #6 name") - FolderItem(name: "Some folder #7 name") - } - Group { - DocumentItem( - name: "Some document #1 name", - lastModified: "3 months ago", - fileSize: "1,23 Mbytes" - ) - DocumentItem( - name: "Some document #2 name", - lastModified: "2 years ago", - fileSize: "123 Kbytes" - ) - DocumentItem( - name: "Some document #3 name", - lastModified: "13 days ago", - fileSize: "12 bytes" - ) - DocumentItem( - name: "Some document #4 name", - lastModified: "13 hours ago", - fileSize: "12,3 Gbytes" - ) - DocumentItem( - name: "Some document #5 name", - lastModified: "13 minutes ago", - fileSize: "123 Tbytes" - ) - DocumentItem( - name: "Some document #6 name", - lastModified: "13 seconds ago", - fileSize: "123 Tbytes" - ) - DocumentItem( - name: "Some document #7 name", - lastModified: "13 nanoseconds ago", - fileSize: "123 Tbytes" - ) + content + .navigationTitle(folder.name) + .toolbar { + BrowseToolbar( + createFolder: createFolder, + uploadFile: uploadFile, + showProfile: showProfile + ) + } + .task(id: folder) { + await loadItems() + } + } +} + +// MARK: - UI + +private extension BrowseView { + + // MARK: Properties + + @ViewBuilder var content: some View { + switch status { + case .noCredentials: + MessageView( + type: .noCredentials, + action: login + ) + case .loading: + LoadingView() + case .loaded: + List(items, id: \.id) { item in + switch item { + case is Folder: + makeFolderItem(for: item) + case is Document: + DocumentItem(item: item) { + // TODO: show the item id in a viewer... + } download: { + // TODO: download the item id from the backend. + } delete: { + // TODO: delete the item id from the backend. + } + default: + EmptyView() } } - .swipeActions( - edge: .trailing, - allowsFullSwipe: true - ) { - Button { - // TODO: Implement the removal of the item from the API. - } label: { - Label { - Text( - "browse.swipe_action.delete_item.text", - bundle: .module, - comment: "Delete item swipe action text." - ) - } icon: { - Image.trash - } + .listStyle(.inset) + case .empty: + MessageView( + type: .empty, + action: uploadFile + ) + case .error: + MessageView(type: .error) { + Task { + await loadItems() } - .tint(.red) - - // TODO: allow download only if item is a file. - Button { - // TODO: Implement the downloading of the data of the item from the API into the device. - } label: { - Label { - Text( - "browse.swipe_action.download_item.text", - bundle: .module, - comment: "Download item swipe action text." - ) - } icon: { - Image.download - } - } - .tint(.orange) } } - .listStyle(.inset) - .background(Color.red) - .navigationTitle("Folder name") - .toolbar { - BrowseToolbar( + } + + // MARK: Functions + + @ViewBuilder func makeFolderItem( + for item: any FileSystemItemIdentifiable + ) -> some View { + let folder = Folder( + id: item.id, + name: item.name + ) + + FolderItem(item: item) { + stack = .browse(folder) + } delete: { + // TODO: delete the item id from the backend. + } + .navigate( + to: BrowseView( + folder: folder, createFolder: createFolder, uploadFile: uploadFile, - showProfile: showProfile + showProfile: showProfile, + login: login + ), + tagged: .browse(folder), + in: $stack + ) + } + +} + +// MARK: - Helpers + +private extension BrowseView { + func loadItems() async { + guard let account else { + status = .noCredentials + return + } + + do { + status = .loading + + let loadedItems = try await getItems( + id: folder.id, + username: account.username, + password: account.password ) + + if loadedItems.isEmpty { + status = .empty + } else { + items = loadedItems + status = .loaded + } + } catch { + status = .error } } } -// MARK: - Image+Constants +// MARK: - Enumerations -private extension Image { - static let trash = Image(systemName: "trash") - static let download = Image(systemName: "arrow.down.doc") +private extension BrowseView { + enum ViewStatus { + case noCredentials + case loading + case loaded + case empty + case error + } } // MARK: - Previews @@ -142,12 +190,17 @@ private extension Image { struct BrowseView_Previews: PreviewProvider { static var previews: some View { NavigationView { - BrowseView { - // ... + BrowseView(folder: .init( + id: UUID().uuidString, + name: "Some folder name" + )) { + // create folder closure. } uploadFile: { - // ... + // upload file closure. } showProfile: { - // ... + // show profile closure. + } login: { + // login closure. } } } diff --git a/Modules/Sources/Login/Logic/Defines/Typealiases.swift b/Modules/Sources/Login/Logic/Defines/Typealiases.swift deleted file mode 100644 index b65b046..0000000 --- a/Modules/Sources/Login/Logic/Defines/Typealiases.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// Typealiases.swift -// Login -// -// Created by Javier Cicchelli on 12/12/2022. -// Copyright © 2022 Röck+Cöde. All rights reserved. -// - -import DataModels - -public typealias AuthenticatedClosure = (Account, User) -> Void diff --git a/Modules/Sources/Login/Resources/en.lproj/Localizable.strings b/Modules/Sources/Login/Resources/en.lproj/Localizable.strings index f571afc..38fe90b 100644 --- a/Modules/Sources/Login/Resources/en.lproj/Localizable.strings +++ b/Modules/Sources/Login/Resources/en.lproj/Localizable.strings @@ -6,10 +6,11 @@ Copyright © 2022 Röck+Cöde. All rights reserved. */ +// LoginView + "login.title.text" = "My files"; "login.text_field.username.placeholder" = "Username"; "login.text_field.password.placeholder" = "Password"; "login.button.log_in.text" = "Log in"; - -"login.error.authentication_failed.text" = "The given username and/or password are not correct.\nPlease re-enter your credentials and try again."; -"login.error.authentication_unknown.text" = "An unexpected error occurred while trying to authenticate your credentials.\nPlease try again at a later time."; +"login.error.authentication_failed.text" = "The provided username and/or password do not match your records.\n\nPlease confirm your credentials and try again."; +"login.error.authentication_unknown.text" = "An unexpected error occurred while trying to authenticate your credentials.\n\nPlease try again at a later time."; diff --git a/Modules/Sources/Login/UI/Components/LoginForm.swift b/Modules/Sources/Login/UI/Components/LoginForm.swift index 5bb0711..fe2d598 100644 --- a/Modules/Sources/Login/UI/Components/LoginForm.swift +++ b/Modules/Sources/Login/UI/Components/LoginForm.swift @@ -79,15 +79,16 @@ struct LoginForm: View { comment: "The error message received from the backend." ) ) + .foregroundColor(.secondary) .font(.body) - .foregroundColor(.red) - .frame(maxWidth: .infinity) .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 24) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) - .background(Color.primary.colorInvert()) + .background(Color.secondary.colorInvert()) .cornerRadius(8) .onAppear { setClearButtonIfNeeded() diff --git a/Modules/Sources/Login/UI/Views/LoginView.swift b/Modules/Sources/Login/UI/Views/LoginView.swift index 6dbb018..a88dade 100644 --- a/Modules/Sources/Login/UI/Views/LoginView.swift +++ b/Modules/Sources/Login/UI/Views/LoginView.swift @@ -7,7 +7,9 @@ // import APIService +import DataModels import SwiftUI +import UseCases public struct LoginView: View { @@ -36,7 +38,8 @@ public struct LoginView: View { .padding(.horizontal, 24) .padding(.top, containerTopPadding) } - .background(Color.red) + .background(Color.primary.colorInvert()) + .ignoresSafeArea() .overlay(ViewHeightGeometry()) .onPreferenceChange(ViewHeightPreferenceKey.self) { height in containerTopPadding = height * 0.1 @@ -60,13 +63,9 @@ fileprivate extension LoginView { // MARK: Properties - private let getUser: GetUserUseCase + let authenticated: AuthenticatedClosure - // MARK: Initialisers - - init(authenticated: @escaping AuthenticatedClosure) { - self.getUser = .init(authenticated: authenticated) - } + private let getUser: GetUserUseCase = .init() // MARK: Body @@ -109,7 +108,7 @@ fileprivate extension LoginView { } .labelStyle(.logIn) } - .tint(.orange) + .tint(.red) .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle(radius: 8)) .controlSize(.large) @@ -138,9 +137,15 @@ private extension LoginView.LoginContainer { guard isAuthenticating else { return } do { - try await getUser( - username: username, - password: password + authenticated( + .init( + username: username, + password: password + ), + try await getUser( + username: username, + password: password + ) ) } catch APIClientError.authenticationFailed { errorMessage = "login.error.authentication_failed.text" @@ -153,6 +158,10 @@ private extension LoginView.LoginContainer { } +// MARK: - Type aliases + +public typealias AuthenticatedClosure = (Account, User) -> Void + // MARK: - Previews struct LoginView_Previews: PreviewProvider { diff --git a/Modules/Sources/Profile/Resources/en.lproj/Localizable.strings b/Modules/Sources/Profile/Resources/en.lproj/Localizable.strings index 683966f..50c09c5 100644 --- a/Modules/Sources/Profile/Resources/en.lproj/Localizable.strings +++ b/Modules/Sources/Profile/Resources/en.lproj/Localizable.strings @@ -10,10 +10,9 @@ "profile.sections.names.label.first_name.text" = "First name"; "profile.sections.names.label.last_name.text" = "Last name"; -"profile.sections.root_info.header.text" = "Root item information"; +"profile.sections.root_info.header.text" = "Root folder"; "profile.sections.root_info.label.identifier.text" = "Identifier"; -"profile.sections.root_info.label.is_directory.text" = "Is a directory?"; -"profile.sections.root_info.label.last_modified.text" = "Last modified"; "profile.sections.root_info.label.name.text" = "Name"; +"profile.sections.root_info.label.last_modified.text" = "Last modified"; "profile.button.log_out.text" = "Log out"; diff --git a/Modules/Sources/Profile/UI/Components/DismissableView.swift b/Modules/Sources/Profile/UI/Components/DismissableView.swift new file mode 100644 index 0000000..ecfc07f --- /dev/null +++ b/Modules/Sources/Profile/UI/Components/DismissableView.swift @@ -0,0 +1,60 @@ +// +// DismissableView.swift +// Profile +// +// Created by Javier Cicchelli on 12/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import SwiftUI + +struct DismissableView: View { + + // MARK: Environments + + @Environment(\.dismiss) private var dismiss + + // MARK: Properties + + @ViewBuilder let content: Content + + // MARK: Body + + var body: some View { + ZStack { + content + + VStack { + Button { + dismiss() + } label: { + Image.close + .resizable() + .scaledToFit() + .frame(width: 32) + .foregroundColor(.secondary) + } + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .trailing) + .padding([.top, .trailing], 24) + } + } +} + +// MARK: - Images+Constants + +private extension Image { + static let close = Image(systemName: "xmark.circle.fill") +} + +// MARK: - Previews + +struct SwiftUIView_Previews: PreviewProvider { + static var previews: some View { + DismissableView { + EmptyView() + } + } +} diff --git a/Modules/Sources/Profile/UI/Views/ProfileView.swift b/Modules/Sources/Profile/UI/Views/ProfileView.swift index 0633db2..e508eb7 100644 --- a/Modules/Sources/Profile/UI/Views/ProfileView.swift +++ b/Modules/Sources/Profile/UI/Views/ProfileView.swift @@ -11,6 +11,10 @@ import SwiftUI public struct ProfileView: View { + // MARK: Environments + + @Environment(\.dismiss) private var dismiss + // MARK: Properties private let user: User? @@ -32,68 +36,73 @@ public struct ProfileView: View { // MARK: Body public var body: some View { - ClearBackgroundList { - Section { - Image.photo - .resizable() - .scaledToFit() - .frame(width: 160) - .frame(maxWidth: .infinity) - } - .listRowBackground(Color.clear) - - ProfileSection( - header: "profile.sections.names.header.text", - items: [ - .init( - key: "profile.sections.names.label.first_name.text", - value: stringAdapter(value: user?.profile.firstName) - ), - .init( - key: "profile.sections.names.label.last_name.text", - value: stringAdapter(value: user?.profile.lastName) - ) - ] - ) - - ProfileSection( - header: "profile.sections.root_info.header.text", - items: [ - .init( - key: "profile.sections.root_info.label.identifier.text", - value: stringAdapter(value: user?.rootFolder.id) - ), - .init( - key: "profile.sections.root_info.label.name.text", - value: stringAdapter(value: user?.rootFolder.name) - ), - .init( - key: "profile.sections.root_info.label.last_modified.text", - value: dateAdapter(value: user?.rootFolder.lastModifiedAt) - ) - ] - ) - - Section { - Button { - logout() - } label: { - Text( - "profile.button.log_out.text", - bundle: .module - ) - .fontWeight(.semibold) - .foregroundColor(.primary) - .frame(maxWidth: .infinity) + DismissableView { + ClearBackgroundList { + Section { + Image.photo + .resizable() + .scaledToFit() + .frame(width: 160) + .frame(maxWidth: .infinity) } - .tint(.orange) - .buttonStyle(.borderedProminent) - .buttonBorderShape(.roundedRectangle(radius: 8)) - .controlSize(.large) + .listRowBackground(Color.clear) + + Group { + ProfileSection( + header: "profile.sections.names.header.text", + items: [ + .init( + key: "profile.sections.names.label.first_name.text", + value: stringAdapter(value: user?.profile.firstName) + ), + .init( + key: "profile.sections.names.label.last_name.text", + value: stringAdapter(value: user?.profile.lastName) + ) + ] + ) + + ProfileSection( + header: "profile.sections.root_info.header.text", + items: [ + .init( + key: "profile.sections.root_info.label.identifier.text", + value: stringAdapter(value: user?.rootFolder.id) + ), + .init( + key: "profile.sections.root_info.label.name.text", + value: stringAdapter(value: user?.rootFolder.name) + ), + .init( + key: "profile.sections.root_info.label.last_modified.text", + value: dateAdapter(value: user?.rootFolder.lastModifiedAt) + ) + ] + ) + } + .listRowBackground(Color.secondary.colorInvert()) + + Section { + Button { + logout() + } label: { + Text( + "profile.button.log_out.text", + bundle: .module + ) + .fontWeight(.semibold) + .foregroundColor(.primary) + .frame(maxWidth: .infinity) + } + .tint(.red) + .controlSize(.large) + .buttonStyle(.borderedProminent) + .buttonBorderShape(.roundedRectangle(radius: 8)) + } + .listRowBackground(Color.clear) } - .listRowBackground(Color.clear) + .background(Color.primary.colorInvert()) } - .background(Color.red) } }