diff --git a/BeReal/ContentView.swift b/BeReal/ContentView.swift index 12650bb..2c544e1 100644 --- a/BeReal/ContentView.swift +++ b/BeReal/ContentView.swift @@ -6,22 +6,42 @@ // Copyright © 2022 Röck+Cöde. All rights reserved. // -import SwiftUI import Browse +import DataModels import Login +import KeychainStorage import Profile +import SwiftUI struct ContentView: View { + + // MARK: Storages + + @KeychainStorage(key: .KeychainStorage.account) private var account: Account? + + // MARK: Body + var body: some View { NavigationView { BrowseView() } - .sheet(isPresented: .constant(true)) { - ProfileView {} + .sheet(isPresented: showLogin) { + LoginView() } } + } +// MARK: - Helpers + +private extension ContentView { + var showLogin: Binding { + .init { account == nil } set: { _ in } + } +} + +// MARK: - Previews + struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() diff --git a/Libraries/Package.swift b/Libraries/Package.swift index 14d92b0..f4e096b 100644 --- a/Libraries/Package.swift +++ b/Libraries/Package.swift @@ -10,6 +10,8 @@ let package = Package( name: "Libraries", targets: [ "APIService", + "DataModels", + "Dependencies", "KeychainStorage" ] ), @@ -23,6 +25,7 @@ let package = Package( ], targets: [ .target(name: "APIService"), + .target(name: "DataModels"), .target( name: "Dependencies", dependencies: [ diff --git a/Libraries/Sources/APIService/Models/Item.swift b/Libraries/Sources/APIService/Models/Item.swift index e607a77..fc82509 100644 --- a/Libraries/Sources/APIService/Models/Item.swift +++ b/Libraries/Sources/APIService/Models/Item.swift @@ -9,13 +9,13 @@ import Foundation public struct Item { - let idParent: String? - let id: String - let name: String - let isDirectory: Bool - let lastModifiedAt: Date - let size: Int? - let contentType: String? + public let idParent: String? + public let id: String + public let name: String + public let isDirectory: Bool + public let lastModifiedAt: Date + public let size: Int? + public let contentType: String? } // MARK: - Decodable diff --git a/Libraries/Sources/APIService/Models/Me.swift b/Libraries/Sources/APIService/Models/Me.swift index 35c1b87..3a3e287 100644 --- a/Libraries/Sources/APIService/Models/Me.swift +++ b/Libraries/Sources/APIService/Models/Me.swift @@ -7,9 +7,9 @@ // public struct Me { - let firstName: String - let lastName: String - let rootItem: Item + public let firstName: String + public let lastName: String + public let rootItem: Item } // MARK: - Decodable diff --git a/Libraries/Sources/DataModels/Keys/String+KeychainStorageKeys.swift b/Libraries/Sources/DataModels/Keys/String+KeychainStorageKeys.swift new file mode 100644 index 0000000..e744ff8 --- /dev/null +++ b/Libraries/Sources/DataModels/Keys/String+KeychainStorageKeys.swift @@ -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" + } +} diff --git a/Libraries/Sources/DataModels/Models/Account.swift b/Libraries/Sources/DataModels/Models/Account.swift new file mode 100644 index 0000000..c0b1711 --- /dev/null +++ b/Libraries/Sources/DataModels/Models/Account.swift @@ -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 + } + +} diff --git a/Libraries/Sources/DataModels/Models/User.swift b/Libraries/Sources/DataModels/Models/User.swift new file mode 100644 index 0000000..6a5e1cc --- /dev/null +++ b/Libraries/Sources/DataModels/Models/User.swift @@ -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 + } + + } +} diff --git a/Libraries/Sources/Dependencies/Keys/APIServiceKey.swift b/Libraries/Sources/Dependencies/Keys/APIServiceKey.swift index e118aaf..88eed55 100644 --- a/Libraries/Sources/Dependencies/Keys/APIServiceKey.swift +++ b/Libraries/Sources/Dependencies/Keys/APIServiceKey.swift @@ -9,6 +9,6 @@ import APIService import DependencyInjection -struct APIServiceKey: DependencyKey { - static var currentValue = APIService() +public struct APIServiceKey: DependencyKey { + public static var currentValue = APIService() } diff --git a/Libraries/Sources/KeychainStorage/Property Wrappers/KeychainStorage.swift b/Libraries/Sources/KeychainStorage/Property Wrappers/KeychainStorage.swift index 11fc43f..34f8332 100644 --- a/Libraries/Sources/KeychainStorage/Property Wrappers/KeychainStorage.swift +++ b/Libraries/Sources/KeychainStorage/Property Wrappers/KeychainStorage.swift @@ -62,13 +62,11 @@ public struct KeychainStorage: DynamicProperty { self.keychain = keychain do { - let data = try keychain.getData(key, ignoringAttributeSynchronizable: true) - - guard let data else { - assertionFailure("The data of the '\(key)' key should have been obtained from the keychain storage.") + guard let data = try keychain.getData(key, ignoringAttributeSynchronizable: true) else { + self._value = .init(initialValue: defaultValue) return } - + do { let model = try decoder.decode(Value.self, from: data) @@ -77,7 +75,7 @@ public struct KeychainStorage: DynamicProperty { assertionFailure("The data for the '\(key)' key should have been decoded properly.") } } catch { - self._value = .init(initialValue: defaultValue) + assertionFailure("The data of the '\(key)' key should have been obtained from the keychain storage.") } } diff --git a/Modules/Package.swift b/Modules/Package.swift index 6e6f8a9..5e4826e 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -5,9 +5,7 @@ import PackageDescription let package = Package( name: "Modules", defaultLocalization: "en", - platforms: [ - .iOS(.v15) - ], + platforms: [.iOS(.v15)], products: [ .library( name: "Modules", @@ -18,9 +16,17 @@ let package = Package( ] ), ], + dependencies: [ + .package(path: "../Cores"), + .package(path: "../Libraries") + ], targets: [ .target( name: "Login", + dependencies: [ + "Cores", + "Libraries" + ], resources: [.process("Resources")] ), .target( diff --git a/Modules/Sources/Login/Resources/en.lproj/Localizable.strings b/Modules/Sources/Login/Resources/en.lproj/Localizable.strings index bd901a9..f571afc 100644 --- a/Modules/Sources/Login/Resources/en.lproj/Localizable.strings +++ b/Modules/Sources/Login/Resources/en.lproj/Localizable.strings @@ -10,3 +10,6 @@ "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."; diff --git a/Modules/Sources/Login/UI/Components/LoginForm.swift b/Modules/Sources/Login/UI/Components/LoginForm.swift index c40fc6c..5bb0711 100644 --- a/Modules/Sources/Login/UI/Components/LoginForm.swift +++ b/Modules/Sources/Login/UI/Components/LoginForm.swift @@ -38,11 +38,7 @@ struct LoginForm: View { comment: "The placeholder for the username text field." ), text: $username - ) { isBeginEditing in - guard isBeginEditing, errorMessage != nil else { return } - - errorMessage = nil - } + ) .textContentType(.username) .lineLimit(1) .autocapitalization(.none) @@ -76,9 +72,17 @@ struct LoginForm: View { if let errorMessage { Divider() - Text(errorMessage) - .font(.body) - .foregroundColor(.red) + Text( + NSLocalizedString( + 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) @@ -88,6 +92,9 @@ struct LoginForm: View { .onAppear { setClearButtonIfNeeded() } + .onChange(of: focusedField) { _ in + onTextFieldFocused() + } } } @@ -101,6 +108,13 @@ private extension LoginForm { textFieldAppearance.clearButtonMode = .whileEditing } + func onTextFieldFocused() { + guard errorMessage != nil else { return } + + password = "" + errorMessage = nil + } + func onUsernameReturnPressed() { guard !username.isEmpty else { return } diff --git a/Modules/Sources/Login/UI/Views/LoginView.swift b/Modules/Sources/Login/UI/Views/LoginView.swift index 392073b..754fe4c 100644 --- a/Modules/Sources/Login/UI/Views/LoginView.swift +++ b/Modules/Sources/Login/UI/Views/LoginView.swift @@ -6,6 +6,11 @@ // Copyright © 2022 Röck+Cöde. All rights reserved. // +import APIService +import DataModels +import DependencyInjection +import Dependencies +import KeychainStorage import SwiftUI public struct LoginView: View { @@ -43,6 +48,14 @@ public struct LoginView: View { fileprivate extension LoginView { struct LoginContainer: View { + // MARK: Dependencies + + @Dependency(\.apiService) private var apiService + + // MARK: Storages + + @KeychainStorage(key: .KeychainStorage.account) private var account: Account? + // MARK: States @State private var isAuthenticating: Bool = false @@ -68,11 +81,11 @@ fileprivate extension LoginView { password: $password, errorMessage: $errorMessage ) { - // TODO: login with the username and password. + isAuthenticating = true } Button { - // TODO: login with the username and password. + isAuthenticating = true } label: { Label { Text( @@ -84,6 +97,7 @@ fileprivate extension LoginView { } icon: { if isAuthenticating { ProgressView() + .controlSize(.regular) } else { EmptyView() } @@ -95,6 +109,9 @@ fileprivate extension LoginView { .buttonBorderShape(.roundedRectangle(radius: 8)) .controlSize(.large) .disabled(isLoginDisabled) + .task(id: isAuthenticating) { + await authenticate() + } } } } @@ -103,9 +120,37 @@ fileprivate extension LoginView { // MARK: - Helpers private extension LoginView.LoginContainer { + + // MARK: Computed + var isLoginDisabled: Bool { 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