Merge pull request #10 from rock-n-code/integration/login

Integration: Login module
This commit is contained in:
Javier Cicchelli 2022-12-11 22:51:26 +01:00 committed by GitHub
commit e7ed33549c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 235 additions and 34 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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