Merge pull request #12 from rock-n-code/integration/browse

Integration: Browse + stack navigation
This commit is contained in:
Javier Cicchelli 2022-12-15 02:31:12 +01:00 committed by GitHub
commit 144ee0f705
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 982 additions and 270 deletions

View File

@ -12,6 +12,7 @@ import Login
import KeychainStorage import KeychainStorage
import Profile import Profile
import SwiftUI import SwiftUI
import UseCases
struct ContentView: View { struct ContentView: View {
@ -24,49 +25,94 @@ struct ContentView: View {
@State private var user: User? @State private var user: User?
@State private var showSheet: SheetView? @State private var showSheet: SheetView?
// MARK: Properties
private let getUser: GetUserUseCase = .init()
// MARK: Body // MARK: Body
var body: some View { var body: some View {
NavigationView { Container(user: user) {
BrowseView { // TODO: create a new folder
// ... } uploadFile: {
} uploadFile: { // TODO: upload a new file
// ... } showProfile: {
} showProfile: { showSheet = .profile
showSheet = .profile } login: {
} showSheet = .login
}
.onAppear {
shouldShowLogin()
}
.onChange(of: account) { _ in
shouldShowLogin()
} }
.sheet(item: $showSheet) { sheet in .sheet(item: $showSheet) { sheet in
switch sheet { switch sheet {
case .login: case .login:
LoginView { LoginView {
user = $1
account = $0 account = $0
user = $1
} }
case .profile: case .profile:
ProfileView(user: user) { ProfileView(user: user) {
user = nil
account = 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 // MARK: - Helpers
private extension ContentView { private extension ContentView {
func shouldShowLogin() { func loadUserOrLogin() async {
showSheet = account == nil guard let account else {
? .login showSheet = .login
: nil return
}
showSheet = nil
user = try? await getUser(
username: account.username,
password: account.password
)
} }
} }

View File

@ -12,7 +12,8 @@ let package = Package(
"APIService", "APIService",
"DataModels", "DataModels",
"Dependencies", "Dependencies",
"KeychainStorage" "KeychainStorage",
"UseCases"
] ]
), ),
], ],
@ -39,6 +40,16 @@ let package = Package(
"KeychainAccess" "KeychainAccess"
] ]
), ),
.target(
name: "UseCases",
dependencies: [
"Cores",
"APIService",
"DataModels",
"Dependencies",
"KeychainStorage"
]
),
.testTarget( .testTarget(
name: "APIServiceTests", name: "APIServiceTests",
dependencies: ["APIService"] dependencies: ["APIService"]

View File

@ -7,25 +7,26 @@
// //
import APIService import APIService
import DataModels
import DependencyInjection import DependencyInjection
import Dependencies import Dependencies
struct GetUserUseCase { public struct GetUserUseCase {
// MARK: Dependencies // MARK: Dependencies
@Dependency(\.apiService) private var apiService @Dependency(\.apiService) private var apiService
// MARK: Properties // MARK: Initialisers
let authenticated: AuthenticatedClosure public init() {}
// MARK: Functions // MARK: Functions
func callAsFunction( public func callAsFunction(
username: String, username: String,
password: String password: String
) async throws { ) async throws -> User {
let me = try await apiService.getUser( let me = try await apiService.getUser(
credentials: .init( credentials: .init(
username: username, username: username,
@ -33,21 +34,15 @@ struct GetUserUseCase {
) )
) )
authenticated( return .init(
.init( profile: .init(
username: username, firstName: me.firstName,
password: password lastName: me.lastName
), ),
.init( rootFolder: .init(
profile: .init( id: me.rootItem.id,
firstName: me.firstName, name: me.rootItem.name,
lastName: me.lastName lastModifiedAt: me.rootItem.lastModifiedAt
),
rootFolder: .init(
id: me.rootItem.id,
name: me.rootItem.name,
lastModifiedAt: me.rootItem.lastModifiedAt
)
) )
) )
} }

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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

View File

@ -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
)
}
}
}
}

View File

@ -6,9 +6,28 @@
Copyright © 2022 Röck+Cöde. All rights reserved. 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.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_folder.text" = "Create a new folder";
"browse.toolbar_item.button.add_file.text" = "Upload a file"; "browse.toolbar_item.button.add_file.text" = "Upload a file";
"browse.toolbar_item.button.show_profile.text" = "Show profile"; "browse.toolbar_item.button.show_profile.text" = "Show profile";
"browse.swipe_action.delete_item.text" = "Delete item"; "browse.swipe_action.delete_item.text" = "Delete item";
"browse.swipe_action.download_item.text" = "Download item"; "browse.swipe_action.download_item.text" = "Download item";

View File

@ -6,42 +6,86 @@
// Copyright © 2022 Röck+Cöde. All rights reserved. // Copyright © 2022 Röck+Cöde. All rights reserved.
// //
import DataModels
import SwiftUI import SwiftUI
struct DocumentItem: View { struct DocumentItem: View {
// MARK: Properties // MARK: Properties
let name: String let item: FileSystemItem
let lastModified: String let select: ActionClosure
let fileSize: String let download: ActionClosure
let delete: ActionClosure
// MARK: Body // MARK: Body
var body: some View { var body: some View {
HStack(spacing: 16) { Button {
Image.document select()
.icon(size: 32) } label: {
.foregroundColor(.red) HStack(spacing: 16) {
Image.document
VStack { .icon(size: 32)
Text(name) .foregroundColor(.red)
.itemName()
HStack { VStack(spacing: 8) {
Text(lastModified) Text(item.name)
.itemName()
Spacer() HStack {
Text("lastModified")
Text(fileSize)
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 // MARK: - Image+Constants
@ -54,18 +98,34 @@ private extension Image {
struct DocumentItem_Previews: PreviewProvider { struct DocumentItem_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
DocumentItem( DocumentItem(item: Document(
id: "1234567890",
name: "Some document name goes in here...", name: "Some document name goes in here...",
lastModified: "Some few hours ago", contentType: "some content type",
fileSize: "23,5 Mbytes" size: .random(in: 1 ... 100),
) lastModifiedAt: .now
)) {
// select closure.
} download: {
// download closure.
} delete: {
// delete closure.
}
.previewDisplayName("Document item") .previewDisplayName("Document item")
DocumentItem( DocumentItem(item: Document(
id: "1234567890",
name: "Some very, extremely long document name goes in here...", name: "Some very, extremely long document name goes in here...",
lastModified: "Yesterday", contentType: "some content type",
fileSize: "235,6 Kbytes" size: .random(in: 1 ... 100),
) lastModifiedAt: .now
)) {
// select closure.
} download: {
// download closure.
} delete: {
// delete closure.
}
.previewDisplayName("Document item with long name") .previewDisplayName("Document item with long name")
} }
} }

View File

@ -6,31 +6,56 @@
// Copyright © 2022 Röck+Cöde. All rights reserved. // Copyright © 2022 Röck+Cöde. All rights reserved.
// //
import DataModels
import SwiftUI import SwiftUI
struct FolderItem: View { struct FolderItem: View {
// MARK: Properties // MARK: Properties
let name: String let item: FileSystemItem
let select: ActionClosure
let delete: ActionClosure
// MARK: Body // MARK: Body
var body: some View { var body: some View {
HStack(spacing: 16) { Button {
Image.folder select()
.icon(size: 32) } label: {
.foregroundColor(.red) HStack(spacing: 16) {
Image.folder
Text(name) .icon(size: 32)
.itemName() .foregroundColor(.red)
Image.chevronRight Text(item.name)
.icon(size: 16) .itemName()
.foregroundColor(.secondary)
.font(.headline) 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 { struct BrowseItem_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
FolderItem(name: "Some folder name goes in here...") FolderItem(item: Folder(
.previewDisplayName("Folder item") 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...") FolderItem(item: Folder(
.previewDisplayName("Folder item with long name") id: "1234567890",
name: "Some very, extremely long folder name goes in here..."
)) {
// select closure.
} delete: {
// delete closure.
}
.previewDisplayName("Folder item with long name")
} }
} }

View File

@ -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()
}
}

View File

@ -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")
}
}

View File

@ -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 = "-"
}
}

View File

@ -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")
}

View File

@ -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<Stack?>
) -> some View {
modifier(StackNavigationViewModifier(
tag: tag,
stack: stack,
destination: { destination }
))
}
}

View File

@ -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<Destination: View>: 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()
}
)
}
}

View File

@ -7,134 +7,182 @@
// //
import DataModels import DataModels
import KeychainStorage
import SwiftUI import SwiftUI
public struct BrowseView: View { 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 // MARK: Properties
private let folder: Folder
private let createFolder: ActionClosure private let createFolder: ActionClosure
private let uploadFile: ActionClosure private let uploadFile: ActionClosure
private let showProfile: ActionClosure private let showProfile: ActionClosure
private let login: ActionClosure
private let getItems: GetItemsUseCase = .init()
// MARK: Initialisers // MARK: Initialisers
public init( public init(
folder: Folder,
createFolder: @escaping ActionClosure, createFolder: @escaping ActionClosure,
uploadFile: @escaping ActionClosure, uploadFile: @escaping ActionClosure,
showProfile: @escaping ActionClosure showProfile: @escaping ActionClosure,
login: @escaping ActionClosure
) { ) {
self.folder = folder
self.createFolder = createFolder self.createFolder = createFolder
self.uploadFile = uploadFile self.uploadFile = uploadFile
self.showProfile = showProfile self.showProfile = showProfile
self.login = login
} }
// MARK: Body // MARK: Body
public var body: some View { public var body: some View {
List { content
Group { .navigationTitle(folder.name)
Group { .toolbar {
FolderItem(name: "Some folder #1 name") BrowseToolbar(
FolderItem(name: "Some folder #2 name") createFolder: createFolder,
FolderItem(name: "Some folder #3 name") uploadFile: uploadFile,
FolderItem(name: "Some folder #4 name") showProfile: showProfile
FolderItem(name: "Some folder #5 name") )
FolderItem(name: "Some folder #6 name") }
FolderItem(name: "Some folder #7 name") .task(id: folder) {
} await loadItems()
Group { }
DocumentItem( }
name: "Some document #1 name", }
lastModified: "3 months ago",
fileSize: "1,23 Mbytes" // MARK: - UI
)
DocumentItem( private extension BrowseView {
name: "Some document #2 name",
lastModified: "2 years ago", // MARK: Properties
fileSize: "123 Kbytes"
) @ViewBuilder var content: some View {
DocumentItem( switch status {
name: "Some document #3 name", case .noCredentials:
lastModified: "13 days ago", MessageView(
fileSize: "12 bytes" type: .noCredentials,
) action: login
DocumentItem( )
name: "Some document #4 name", case .loading:
lastModified: "13 hours ago", LoadingView()
fileSize: "12,3 Gbytes" case .loaded:
) List(items, id: \.id) { item in
DocumentItem( switch item {
name: "Some document #5 name", case is Folder:
lastModified: "13 minutes ago", makeFolderItem(for: item)
fileSize: "123 Tbytes" case is Document:
) DocumentItem(item: item) {
DocumentItem( // TODO: show the item id in a viewer...
name: "Some document #6 name", } download: {
lastModified: "13 seconds ago", // TODO: download the item id from the backend.
fileSize: "123 Tbytes" } delete: {
) // TODO: delete the item id from the backend.
DocumentItem( }
name: "Some document #7 name", default:
lastModified: "13 nanoseconds ago", EmptyView()
fileSize: "123 Tbytes"
)
} }
} }
.swipeActions( .listStyle(.inset)
edge: .trailing, case .empty:
allowsFullSwipe: true MessageView(
) { type: .empty,
Button { action: uploadFile
// TODO: Implement the removal of the item from the API. )
} label: { case .error:
Label { MessageView(type: .error) {
Text( Task {
"browse.swipe_action.delete_item.text", await loadItems()
bundle: .module,
comment: "Delete item swipe action text."
)
} icon: {
Image.trash
}
} }
.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") // MARK: Functions
.toolbar {
BrowseToolbar( @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, createFolder: createFolder,
uploadFile: uploadFile, 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 { private extension BrowseView {
static let trash = Image(systemName: "trash") enum ViewStatus {
static let download = Image(systemName: "arrow.down.doc") case noCredentials
case loading
case loaded
case empty
case error
}
} }
// MARK: - Previews // MARK: - Previews
@ -142,12 +190,17 @@ private extension Image {
struct BrowseView_Previews: PreviewProvider { struct BrowseView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
NavigationView { NavigationView {
BrowseView { BrowseView(folder: .init(
// ... id: UUID().uuidString,
name: "Some folder name"
)) {
// create folder closure.
} uploadFile: { } uploadFile: {
// ... // upload file closure.
} showProfile: { } showProfile: {
// ... // show profile closure.
} login: {
// login closure.
} }
} }
} }

View File

@ -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

View File

@ -6,10 +6,11 @@
Copyright © 2022 Röck+Cöde. All rights reserved. Copyright © 2022 Röck+Cöde. All rights reserved.
*/ */
// LoginView
"login.title.text" = "My files"; "login.title.text" = "My files";
"login.text_field.username.placeholder" = "Username"; "login.text_field.username.placeholder" = "Username";
"login.text_field.password.placeholder" = "Password"; "login.text_field.password.placeholder" = "Password";
"login.button.log_in.text" = "Log in"; "login.button.log_in.text" = "Log in";
"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_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.\n\nPlease try again at a later time.";
"login.error.authentication_unknown.text" = "An unexpected error occurred while trying to authenticate your credentials.\nPlease try again at a later time.";

View File

@ -79,15 +79,16 @@ struct LoginForm: View {
comment: "The error message received from the backend." comment: "The error message received from the backend."
) )
) )
.foregroundColor(.secondary)
.font(.body) .font(.body)
.foregroundColor(.red)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(16) .padding(16)
.background(Color.primary.colorInvert()) .background(Color.secondary.colorInvert())
.cornerRadius(8) .cornerRadius(8)
.onAppear { .onAppear {
setClearButtonIfNeeded() setClearButtonIfNeeded()

View File

@ -7,7 +7,9 @@
// //
import APIService import APIService
import DataModels
import SwiftUI import SwiftUI
import UseCases
public struct LoginView: View { public struct LoginView: View {
@ -36,7 +38,8 @@ public struct LoginView: View {
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.top, containerTopPadding) .padding(.top, containerTopPadding)
} }
.background(Color.red) .background(Color.primary.colorInvert())
.ignoresSafeArea()
.overlay(ViewHeightGeometry()) .overlay(ViewHeightGeometry())
.onPreferenceChange(ViewHeightPreferenceKey.self) { height in .onPreferenceChange(ViewHeightPreferenceKey.self) { height in
containerTopPadding = height * 0.1 containerTopPadding = height * 0.1
@ -60,13 +63,9 @@ fileprivate extension LoginView {
// MARK: Properties // MARK: Properties
private let getUser: GetUserUseCase let authenticated: AuthenticatedClosure
// MARK: Initialisers private let getUser: GetUserUseCase = .init()
init(authenticated: @escaping AuthenticatedClosure) {
self.getUser = .init(authenticated: authenticated)
}
// MARK: Body // MARK: Body
@ -109,7 +108,7 @@ fileprivate extension LoginView {
} }
.labelStyle(.logIn) .labelStyle(.logIn)
} }
.tint(.orange) .tint(.red)
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.buttonBorderShape(.roundedRectangle(radius: 8)) .buttonBorderShape(.roundedRectangle(radius: 8))
.controlSize(.large) .controlSize(.large)
@ -138,9 +137,15 @@ private extension LoginView.LoginContainer {
guard isAuthenticating else { return } guard isAuthenticating else { return }
do { do {
try await getUser( authenticated(
username: username, .init(
password: password username: username,
password: password
),
try await getUser(
username: username,
password: password
)
) )
} catch APIClientError.authenticationFailed { } catch APIClientError.authenticationFailed {
errorMessage = "login.error.authentication_failed.text" 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 // MARK: - Previews
struct LoginView_Previews: PreviewProvider { struct LoginView_Previews: PreviewProvider {

View File

@ -10,10 +10,9 @@
"profile.sections.names.label.first_name.text" = "First name"; "profile.sections.names.label.first_name.text" = "First name";
"profile.sections.names.label.last_name.text" = "Last 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.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.name.text" = "Name";
"profile.sections.root_info.label.last_modified.text" = "Last modified";
"profile.button.log_out.text" = "Log out"; "profile.button.log_out.text" = "Log out";

View File

@ -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<Content: View>: 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()
}
}
}

View File

@ -11,6 +11,10 @@ import SwiftUI
public struct ProfileView: View { public struct ProfileView: View {
// MARK: Environments
@Environment(\.dismiss) private var dismiss
// MARK: Properties // MARK: Properties
private let user: User? private let user: User?
@ -32,68 +36,73 @@ public struct ProfileView: View {
// MARK: Body // MARK: Body
public var body: some View { public var body: some View {
ClearBackgroundList { DismissableView {
Section { ClearBackgroundList {
Image.photo Section {
.resizable() Image.photo
.scaledToFit() .resizable()
.frame(width: 160) .scaledToFit()
.frame(maxWidth: .infinity) .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)
} }
.tint(.orange) .listRowBackground(Color.clear)
.buttonStyle(.borderedProminent)
.buttonBorderShape(.roundedRectangle(radius: 8)) Group {
.controlSize(.large) 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)
} }
} }