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

View File

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

View File

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

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.
*/
// 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";

View File

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

View File

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

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 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.
}
}
}

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.
*/
// 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.";

View File

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

View File

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

View File

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

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 {
// 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)
}
}