diff --git a/BeReal.xcodeproj/project.pbxproj b/BeReal.xcodeproj/project.pbxproj index 978e113..c9f7f8b 100644 --- a/BeReal.xcodeproj/project.pbxproj +++ b/BeReal.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 02AE650029363DC1005A4AF3 /* BeRealTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AE64FF29363DC1005A4AF3 /* BeRealTests.swift */; }; 02AE650A29363DC1005A4AF3 /* BeRealUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AE650929363DC1005A4AF3 /* BeRealUITests.swift */; }; 02AE650C29363DC1005A4AF3 /* BeRealUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AE650B29363DC1005A4AF3 /* BeRealUITestsLaunchTests.swift */; }; + 02FFFD7B29395DD200306533 /* String+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FFFD7A29395DD200306533 /* String+Constants.swift */; }; + 4694AAA0293A7C8800D54903 /* Modules in Frameworks */ = {isa = PBXBuildFile; productRef = 4694AA9F293A7C8800D54903 /* Modules */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -34,6 +36,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 02784F03293A8331005F839D /* Modules */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Modules; sourceTree = ""; }; 02AE64EB29363DBF005A4AF3 /* BeReal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BeReal.app; sourceTree = BUILT_PRODUCTS_DIR; }; 02AE64EE29363DBF005A4AF3 /* BeRealApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeRealApp.swift; sourceTree = ""; }; 02AE64F029363DBF005A4AF3 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -44,6 +47,7 @@ 02AE650529363DC1005A4AF3 /* BeRealUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BeRealUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 02AE650929363DC1005A4AF3 /* BeRealUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeRealUITests.swift; sourceTree = ""; }; 02AE650B29363DC1005A4AF3 /* BeRealUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeRealUITestsLaunchTests.swift; sourceTree = ""; }; + 02FFFD7A29395DD200306533 /* String+Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Constants.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -51,6 +55,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4694AAA0293A7C8800D54903 /* Modules in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -71,13 +76,22 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 027F60592937662300467238 /* Login */ = { + isa = PBXGroup; + children = ( + ); + path = Login; + sourceTree = ""; + }; 02AE64E229363DBF005A4AF3 = { isa = PBXGroup; children = ( + 02784F03293A8331005F839D /* Modules */, 02AE64ED29363DBF005A4AF3 /* BeReal */, 02AE64FE29363DC1005A4AF3 /* BeRealTests */, 02AE650829363DC1005A4AF3 /* BeRealUITests */, 02AE64EC29363DBF005A4AF3 /* Products */, + 4694AA9E293A7C8800D54903 /* Frameworks */, ); sourceTree = ""; }; @@ -94,6 +108,8 @@ 02AE64ED29363DBF005A4AF3 /* BeReal */ = { isa = PBXGroup; children = ( + 02FFFD7929395DBF00306533 /* Extensions */, + 027F60592937662300467238 /* Login */, 02AE64EE29363DBF005A4AF3 /* BeRealApp.swift */, 02AE64F029363DBF005A4AF3 /* ContentView.swift */, 02AE64F229363DC1005A4AF3 /* Assets.xcassets */, @@ -127,6 +143,21 @@ path = BeRealUITests; sourceTree = ""; }; + 02FFFD7929395DBF00306533 /* Extensions */ = { + isa = PBXGroup; + children = ( + 02FFFD7A29395DD200306533 /* String+Constants.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 4694AA9E293A7C8800D54903 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -143,6 +174,9 @@ dependencies = ( ); name = BeReal; + packageProductDependencies = ( + 4694AA9F293A7C8800D54903 /* Modules */, + ); productName = BeReal; productReference = 02AE64EB29363DBF005A4AF3 /* BeReal.app */; productType = "com.apple.product-type.application"; @@ -260,6 +294,7 @@ files = ( 02AE64F129363DBF005A4AF3 /* ContentView.swift in Sources */, 02AE64EF29363DBF005A4AF3 /* BeRealApp.swift in Sources */, + 02FFFD7B29395DD200306533 /* String+Constants.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -608,6 +643,13 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4694AA9F293A7C8800D54903 /* Modules */ = { + isa = XCSwiftPackageProductDependency; + productName = Modules; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 02AE64E329363DBF005A4AF3 /* Project object */; } diff --git a/BeReal/ContentView.swift b/BeReal/ContentView.swift index a00be8d..e9aef05 100644 --- a/BeReal/ContentView.swift +++ b/BeReal/ContentView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import Login struct ContentView: View { var body: some View { @@ -17,6 +18,9 @@ struct ContentView: View { Text("Hello, world!") } .padding() + .sheet(isPresented: .constant(true)) { + LoginView() + } } } diff --git a/BeReal/Extensions/String+Constants.swift b/BeReal/Extensions/String+Constants.swift new file mode 100644 index 0000000..b4df93d --- /dev/null +++ b/BeReal/Extensions/String+Constants.swift @@ -0,0 +1,11 @@ +// +// String+Constants.swift +// BeReal +// +// Created by Javier Cicchelli on 01/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +extension String { + static let empty = "" +} diff --git a/Modules/Package.swift b/Modules/Package.swift new file mode 100644 index 0000000..98ab061 --- /dev/null +++ b/Modules/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 5.7 + +import PackageDescription + +let package = Package( + name: "Modules", + defaultLocalization: "en", + platforms: [ + .iOS(.v15) + ], + products: [ + .library( + name: "Modules", + targets: [ + "Login" + ] + ), + ], + targets: [ + .target( + name: "Login", + resources: [.process("Resources")] + ), + ] +) diff --git a/Modules/Sources/Login/Resources/en.lproj/Localizable.strings b/Modules/Sources/Login/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..bd901a9 --- /dev/null +++ b/Modules/Sources/Login/Resources/en.lproj/Localizable.strings @@ -0,0 +1,12 @@ +/* + Localizable.strings + Login + + Created by Javier Cicchelli on 02/12/2022. + Copyright © 2022 Röck+Cöde. All rights reserved. +*/ + +"login.title.text" = "My files"; +"login.text_field.username.placeholder" = "Username"; +"login.text_field.password.placeholder" = "Password"; +"login.button.log_in.text" = "Log in"; diff --git a/Modules/Sources/Login/UI/Components/LoginForm.swift b/Modules/Sources/Login/UI/Components/LoginForm.swift new file mode 100644 index 0000000..c40fc6c --- /dev/null +++ b/Modules/Sources/Login/UI/Components/LoginForm.swift @@ -0,0 +1,154 @@ +// +// LoginForm.swift +// Login +// +// Created by Javier Cicchelli on 01/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import SwiftUI + +struct LoginForm: View { + + // MARK: States + + @FocusState private var focusedField: Field? + + // MARK: Bindings + + @Binding var username: String + @Binding var password: String + @Binding var errorMessage: String? + + // MARK: Properties + + let onReturn: () -> Void + + // MARK: Body + + var body: some View { + VStack( + alignment: .leading, + spacing: 16 + ) { + TextField( + NSLocalizedString( + "login.text_field.username.placeholder", + bundle: .module, + 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) + .disableAutocorrection(true) + .keyboardType(.default) + .focused($focusedField, equals: .username) + .onSubmit { + onUsernameReturnPressed() + } + + Divider() + + SecureField( + NSLocalizedString( + "login.text_field.password.placeholder", + bundle: .module, + comment: "The placeholder for the password text field." + ), + text: $password + ) + .textContentType(.password) + .lineLimit(1) + .autocapitalization(.none) + .disableAutocorrection(true) + .keyboardType(.default) + .focused($focusedField, equals: .password) + .onSubmit { + onPasswordReturnPressed() + } + + if let errorMessage { + Divider() + + Text(errorMessage) + .font(.body) + .foregroundColor(.red) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color.primary.colorInvert()) + .cornerRadius(8) + .onAppear { + setClearButtonIfNeeded() + } + } +} + +// MARK: - Helpers + +private extension LoginForm { + func setClearButtonIfNeeded() { + let textFieldAppearance = UITextField.appearance() + guard textFieldAppearance.clearButtonMode != .whileEditing else { return } + + textFieldAppearance.clearButtonMode = .whileEditing + } + + func onUsernameReturnPressed() { + guard !username.isEmpty else { return } + + if password.isEmpty { + focusedField = .password + } else { + onReturn() + } + } + + func onPasswordReturnPressed() { + guard !password.isEmpty else { return } + + if username.isEmpty { + focusedField = .username + } else { + onReturn() + } + } +} + +// MARK: - Enumerations + +private extension LoginForm { + enum Field: Hashable { + case username + case password + } +} + +// MARK: - Previews + +struct LoginForm_Previews: PreviewProvider { + static var previews: some View { + LoginForm( + username: .constant("Some username"), + password: .constant("Some Password"), + errorMessage: .constant(nil), + onReturn: {} + ) + .previewDisplayName("Login form with no error message") + + LoginForm( + username: .constant("Some username"), + password: .constant("Some Password"), + errorMessage: .constant("Some error goes in here..."), + onReturn: {} + ) + .previewDisplayName("Login form with some error message") + } +} diff --git a/Modules/Sources/Login/UI/Components/ViewHeightGeometry.swift b/Modules/Sources/Login/UI/Components/ViewHeightGeometry.swift new file mode 100644 index 0000000..7f0672a --- /dev/null +++ b/Modules/Sources/Login/UI/Components/ViewHeightGeometry.swift @@ -0,0 +1,22 @@ +// +// ViewHeightGeometry.swift +// Login +// +// Created by Javier Cicchelli on 02/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import SwiftUI + +struct ViewHeightGeometry: View { + var body: some View { + GeometryReader { proxy in + Color.clear.preference( + key: ViewHeightPreferenceKey.self, + value: proxy.size.height + + proxy.safeAreaInsets.top + + proxy.safeAreaInsets.bottom + ) + } + } +} diff --git a/Modules/Sources/Login/UI/Preference Keys/ViewHeightPreferenceKey.swift b/Modules/Sources/Login/UI/Preference Keys/ViewHeightPreferenceKey.swift new file mode 100644 index 0000000..2dfc680 --- /dev/null +++ b/Modules/Sources/Login/UI/Preference Keys/ViewHeightPreferenceKey.swift @@ -0,0 +1,17 @@ +// +// ViewHeightPreferenceKey.swift +// Login +// +// Created by Javier Cicchelli on 02/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import SwiftUI + +struct ViewHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} diff --git a/Modules/Sources/Login/UI/Styles/LogInLabelStyle.swift b/Modules/Sources/Login/UI/Styles/LogInLabelStyle.swift new file mode 100644 index 0000000..a025527 --- /dev/null +++ b/Modules/Sources/Login/UI/Styles/LogInLabelStyle.swift @@ -0,0 +1,34 @@ +// +// LogInLabelStyle.swift +// Login +// +// Created by Javier Cicchelli on 02/12/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import SwiftUI + +struct LogInLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: 8) { + Spacer() + + configuration.title + .font(.body) + .foregroundColor(.primary) + + configuration.icon + .tint(.primary) + + Spacer() + } + } +} + +// MARK: - LabelStyle + +extension LabelStyle where Self == LogInLabelStyle { + static var logIn: Self { + LogInLabelStyle() + } +} diff --git a/Modules/Sources/Login/UI/Views/LoginView.swift b/Modules/Sources/Login/UI/Views/LoginView.swift new file mode 100644 index 0000000..392073b --- /dev/null +++ b/Modules/Sources/Login/UI/Views/LoginView.swift @@ -0,0 +1,117 @@ +// +// LoginView.swift +// Login +// +// Created by Javier Cicchelli on 30/11/2022. +// Copyright © 2022 Röck+Cöde. All rights reserved. +// + +import SwiftUI + +public struct LoginView: View { + + // MARK: States + + @State private var containerTopPadding: CGFloat = 0 + + // MARK: Initialisers + + public init() {} + + // MARK: Body + + public var body: some View { + ScrollView( + .vertical, + showsIndicators: false + ) { + LoginContainer() + .padding(.horizontal, 24) + .padding(.top, containerTopPadding) + } + .background(Color.red) + .overlay(ViewHeightGeometry()) + .onPreferenceChange(ViewHeightPreferenceKey.self) { height in + containerTopPadding = height * 0.1 + } + } + +} + +// MARK: - Views + +fileprivate extension LoginView { + struct LoginContainer: View { + + // MARK: States + + @State private var isAuthenticating: Bool = false + @State private var username: String = "" + @State private var password: String = "" + @State private var errorMessage: String? + + // MARK: Body + + var body: some View { + VStack(spacing: 32) { + Text( + "login.title.text", + bundle: .module, + comment: "Login view title text." + ) + .font(.largeTitle) + .fontWeight(.bold) + .foregroundColor(.primary) + + LoginForm( + username: $username, + password: $password, + errorMessage: $errorMessage + ) { + // TODO: login with the username and password. + } + + Button { + // TODO: login with the username and password. + } label: { + Label { + Text( + "login.button.log_in.text", + bundle: .module, + comment: "Log in button text." + ) + .fontWeight(.semibold) + } icon: { + if isAuthenticating { + ProgressView() + } else { + EmptyView() + } + } + .labelStyle(.logIn) + } + .tint(.orange) + .buttonStyle(.borderedProminent) + .buttonBorderShape(.roundedRectangle(radius: 8)) + .controlSize(.large) + .disabled(isLoginDisabled) + } + } + } +} + +// MARK: - Helpers + +private extension LoginView.LoginContainer { + var isLoginDisabled: Bool { + username.isEmpty || password.isEmpty + } +} + +// MARK: - Previews + +struct LoginView_Previews: PreviewProvider { + static var previews: some View { + LoginView() + } +}