Merge pull request #10 from rock-n-code/integration/login
Integration: Login module
This commit is contained in:
commit
e7ed33549c
@ -6,22 +6,42 @@
|
|||||||
// Copyright © 2022 Röck+Cöde. All rights reserved.
|
// Copyright © 2022 Röck+Cöde. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Browse
|
import Browse
|
||||||
|
import DataModels
|
||||||
import Login
|
import Login
|
||||||
|
import KeychainStorage
|
||||||
import Profile
|
import Profile
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
|
|
||||||
|
// MARK: Storages
|
||||||
|
|
||||||
|
@KeychainStorage(key: .KeychainStorage.account) private var account: Account?
|
||||||
|
|
||||||
|
// MARK: Body
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
BrowseView()
|
BrowseView()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: .constant(true)) {
|
.sheet(isPresented: showLogin) {
|
||||||
ProfileView {}
|
LoginView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension ContentView {
|
||||||
|
var showLogin: Binding<Bool> {
|
||||||
|
.init { account == nil } set: { _ in }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
struct ContentView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
@ -10,6 +10,8 @@ let package = Package(
|
|||||||
name: "Libraries",
|
name: "Libraries",
|
||||||
targets: [
|
targets: [
|
||||||
"APIService",
|
"APIService",
|
||||||
|
"DataModels",
|
||||||
|
"Dependencies",
|
||||||
"KeychainStorage"
|
"KeychainStorage"
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
@ -23,6 +25,7 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "APIService"),
|
.target(name: "APIService"),
|
||||||
|
.target(name: "DataModels"),
|
||||||
.target(
|
.target(
|
||||||
name: "Dependencies",
|
name: "Dependencies",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
@ -9,13 +9,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Item {
|
public struct Item {
|
||||||
let idParent: String?
|
public let idParent: String?
|
||||||
let id: String
|
public let id: String
|
||||||
let name: String
|
public let name: String
|
||||||
let isDirectory: Bool
|
public let isDirectory: Bool
|
||||||
let lastModifiedAt: Date
|
public let lastModifiedAt: Date
|
||||||
let size: Int?
|
public let size: Int?
|
||||||
let contentType: String?
|
public let contentType: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Decodable
|
// MARK: - Decodable
|
||||||
|
@ -7,9 +7,9 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
public struct Me {
|
public struct Me {
|
||||||
let firstName: String
|
public let firstName: String
|
||||||
let lastName: String
|
public let lastName: String
|
||||||
let rootItem: Item
|
public let rootItem: Item
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Decodable
|
// MARK: - Decodable
|
||||||
|
@ -0,0 +1,13 @@
|
|||||||
|
//
|
||||||
|
// String+KeychainStorageKeys.swift
|
||||||
|
// DataModels
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 11/12/2022.
|
||||||
|
// Copyright © 2022 Röck+Cöde. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
public extension String {
|
||||||
|
enum KeychainStorage {
|
||||||
|
public static let account = "com.rockncode.app.assignment.be-real.library.data-models.keychain.key.account"
|
||||||
|
}
|
||||||
|
}
|
26
Libraries/Sources/DataModels/Models/Account.swift
Normal file
26
Libraries/Sources/DataModels/Models/Account.swift
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
//
|
||||||
|
// Account.swift
|
||||||
|
// DataModels
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 11/12/2022.
|
||||||
|
// Copyright © 2022 Röck+Cöde. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
public struct Account: Codable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
public let username: String
|
||||||
|
public let password: String
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
|
||||||
|
public init(
|
||||||
|
username: String,
|
||||||
|
password: String
|
||||||
|
) {
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
73
Libraries/Sources/DataModels/Models/User.swift
Normal file
73
Libraries/Sources/DataModels/Models/User.swift
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// User.swift
|
||||||
|
// DataModels
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 11/12/2022.
|
||||||
|
// Copyright © 2022 Röck+Cöde. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct User {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
public let profile: Profile
|
||||||
|
public let rootFolder: RootFolder
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
|
||||||
|
public init(
|
||||||
|
profile: Profile,
|
||||||
|
rootFolder: RootFolder
|
||||||
|
) {
|
||||||
|
self.profile = profile
|
||||||
|
self.rootFolder = rootFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Structs
|
||||||
|
|
||||||
|
extension User {
|
||||||
|
public struct Profile {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
public let firstName: String
|
||||||
|
public let lastName: String
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
|
||||||
|
public init(
|
||||||
|
firstName: String,
|
||||||
|
lastName: String
|
||||||
|
) {
|
||||||
|
self.firstName = firstName
|
||||||
|
self.lastName = lastName
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RootFolder {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
public let id: String
|
||||||
|
public let name: String
|
||||||
|
public let lastModifiedAt: Date
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
lastModifiedAt: Date
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.lastModifiedAt = lastModifiedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
@ -9,6 +9,6 @@
|
|||||||
import APIService
|
import APIService
|
||||||
import DependencyInjection
|
import DependencyInjection
|
||||||
|
|
||||||
struct APIServiceKey: DependencyKey {
|
public struct APIServiceKey: DependencyKey {
|
||||||
static var currentValue = APIService()
|
public static var currentValue = APIService()
|
||||||
}
|
}
|
||||||
|
@ -62,13 +62,11 @@ public struct KeychainStorage<Model: Codable>: DynamicProperty {
|
|||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try keychain.getData(key, ignoringAttributeSynchronizable: true)
|
guard let data = try keychain.getData(key, ignoringAttributeSynchronizable: true) else {
|
||||||
|
self._value = .init(initialValue: defaultValue)
|
||||||
guard let data else {
|
|
||||||
assertionFailure("The data of the '\(key)' key should have been obtained from the keychain storage.")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let model = try decoder.decode(Value.self, from: data)
|
let model = try decoder.decode(Value.self, from: data)
|
||||||
|
|
||||||
@ -77,7 +75,7 @@ public struct KeychainStorage<Model: Codable>: DynamicProperty {
|
|||||||
assertionFailure("The data for the '\(key)' key should have been decoded properly.")
|
assertionFailure("The data for the '\(key)' key should have been decoded properly.")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self._value = .init(initialValue: defaultValue)
|
assertionFailure("The data of the '\(key)' key should have been obtained from the keychain storage.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,9 +5,7 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "Modules",
|
name: "Modules",
|
||||||
defaultLocalization: "en",
|
defaultLocalization: "en",
|
||||||
platforms: [
|
platforms: [.iOS(.v15)],
|
||||||
.iOS(.v15)
|
|
||||||
],
|
|
||||||
products: [
|
products: [
|
||||||
.library(
|
.library(
|
||||||
name: "Modules",
|
name: "Modules",
|
||||||
@ -18,9 +16,17 @@ let package = Package(
|
|||||||
]
|
]
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(path: "../Cores"),
|
||||||
|
.package(path: "../Libraries")
|
||||||
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "Login",
|
name: "Login",
|
||||||
|
dependencies: [
|
||||||
|
"Cores",
|
||||||
|
"Libraries"
|
||||||
|
],
|
||||||
resources: [.process("Resources")]
|
resources: [.process("Resources")]
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
|
@ -10,3 +10,6 @@
|
|||||||
"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 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.";
|
||||||
|
@ -38,11 +38,7 @@ struct LoginForm: View {
|
|||||||
comment: "The placeholder for the username text field."
|
comment: "The placeholder for the username text field."
|
||||||
),
|
),
|
||||||
text: $username
|
text: $username
|
||||||
) { isBeginEditing in
|
)
|
||||||
guard isBeginEditing, errorMessage != nil else { return }
|
|
||||||
|
|
||||||
errorMessage = nil
|
|
||||||
}
|
|
||||||
.textContentType(.username)
|
.textContentType(.username)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
@ -76,9 +72,17 @@ struct LoginForm: View {
|
|||||||
if let errorMessage {
|
if let errorMessage {
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Text(errorMessage)
|
Text(
|
||||||
.font(.body)
|
NSLocalizedString(
|
||||||
.foregroundColor(.red)
|
errorMessage,
|
||||||
|
bundle: .module,
|
||||||
|
comment: "The error message received from the backend."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@ -88,6 +92,9 @@ struct LoginForm: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
setClearButtonIfNeeded()
|
setClearButtonIfNeeded()
|
||||||
}
|
}
|
||||||
|
.onChange(of: focusedField) { _ in
|
||||||
|
onTextFieldFocused()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,6 +108,13 @@ private extension LoginForm {
|
|||||||
textFieldAppearance.clearButtonMode = .whileEditing
|
textFieldAppearance.clearButtonMode = .whileEditing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func onTextFieldFocused() {
|
||||||
|
guard errorMessage != nil else { return }
|
||||||
|
|
||||||
|
password = ""
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
func onUsernameReturnPressed() {
|
func onUsernameReturnPressed() {
|
||||||
guard !username.isEmpty else { return }
|
guard !username.isEmpty else { return }
|
||||||
|
|
||||||
|
@ -6,6 +6,11 @@
|
|||||||
// Copyright © 2022 Röck+Cöde. All rights reserved.
|
// Copyright © 2022 Röck+Cöde. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import APIService
|
||||||
|
import DataModels
|
||||||
|
import DependencyInjection
|
||||||
|
import Dependencies
|
||||||
|
import KeychainStorage
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
public struct LoginView: View {
|
public struct LoginView: View {
|
||||||
@ -43,6 +48,14 @@ public struct LoginView: View {
|
|||||||
fileprivate extension LoginView {
|
fileprivate extension LoginView {
|
||||||
struct LoginContainer: View {
|
struct LoginContainer: View {
|
||||||
|
|
||||||
|
// MARK: Dependencies
|
||||||
|
|
||||||
|
@Dependency(\.apiService) private var apiService
|
||||||
|
|
||||||
|
// MARK: Storages
|
||||||
|
|
||||||
|
@KeychainStorage(key: .KeychainStorage.account) private var account: Account?
|
||||||
|
|
||||||
// MARK: States
|
// MARK: States
|
||||||
|
|
||||||
@State private var isAuthenticating: Bool = false
|
@State private var isAuthenticating: Bool = false
|
||||||
@ -68,11 +81,11 @@ fileprivate extension LoginView {
|
|||||||
password: $password,
|
password: $password,
|
||||||
errorMessage: $errorMessage
|
errorMessage: $errorMessage
|
||||||
) {
|
) {
|
||||||
// TODO: login with the username and password.
|
isAuthenticating = true
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
// TODO: login with the username and password.
|
isAuthenticating = true
|
||||||
} label: {
|
} label: {
|
||||||
Label {
|
Label {
|
||||||
Text(
|
Text(
|
||||||
@ -84,6 +97,7 @@ fileprivate extension LoginView {
|
|||||||
} icon: {
|
} icon: {
|
||||||
if isAuthenticating {
|
if isAuthenticating {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
.controlSize(.regular)
|
||||||
} else {
|
} else {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
@ -95,6 +109,9 @@ fileprivate extension LoginView {
|
|||||||
.buttonBorderShape(.roundedRectangle(radius: 8))
|
.buttonBorderShape(.roundedRectangle(radius: 8))
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
.disabled(isLoginDisabled)
|
.disabled(isLoginDisabled)
|
||||||
|
.task(id: isAuthenticating) {
|
||||||
|
await authenticate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -103,9 +120,37 @@ fileprivate extension LoginView {
|
|||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private extension LoginView.LoginContainer {
|
private extension LoginView.LoginContainer {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
var isLoginDisabled: Bool {
|
var isLoginDisabled: Bool {
|
||||||
username.isEmpty || password.isEmpty
|
username.isEmpty || password.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
|
||||||
|
func authenticate() async {
|
||||||
|
guard isAuthenticating else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await apiService.getUser(credentials: .init(
|
||||||
|
username: username,
|
||||||
|
password: password
|
||||||
|
))
|
||||||
|
|
||||||
|
account = .init(
|
||||||
|
username: username,
|
||||||
|
password: password
|
||||||
|
)
|
||||||
|
} catch APIClientError.authenticationFailed {
|
||||||
|
errorMessage = "login.error.authentication_failed.text"
|
||||||
|
isAuthenticating = false
|
||||||
|
} catch {
|
||||||
|
errorMessage = "login.error.authentication_unknown.text"
|
||||||
|
isAuthenticating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
Loading…
x
Reference in New Issue
Block a user