Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SignIn Screen, in SwiftUI #544

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions BeeSwift.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@
objects = {

/* Begin PBXBuildFile section */
9B2488422D030E5100E610E0 /* SignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2488412D030E5100E610E0 /* SignInView.swift */; };
9B65F2322CFA6427009674A7 /* DeeplinkGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B65F2312CFA6418009674A7 /* DeeplinkGenerator.swift */; };
9B6F0CE82D2011F00077D37D /* SignInButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B6F0CE72D2011F00077D37D /* SignInButton.swift */; };
9B8CA57D24B120CA009C86C2 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9B8CA57C24B120CA009C86C2 /* LaunchScreen.storyboard */; };
A10D4E931B07948500A72D29 /* DatapointsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10D4E921B07948500A72D29 /* DatapointsTableView.swift */; };
A10DC2DF207BFCBA00FB7B3A /* RemoveHKMetricViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10DC2DE207BFCBA00FB7B3A /* RemoveHKMetricViewController.swift */; };
A11A87C61FEBFF7200A43E47 /* ChooseGoalSortViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A87C51FEBFF7200A43E47 /* ChooseGoalSortViewController.swift */; };
A11BC2D91FFAD5BC00E56064 /* TimerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11BC2D81FFAD5BC00E56064 /* TimerViewController.swift */; };
A12BA94E1AFF202200AFEF32 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A12BA94D1AFF202200AFEF32 /* SystemConfiguration.framework */; };
A1453B3F1AEDFCC8006F48DA /* SignInViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1453B3E1AEDFCC8006F48DA /* SignInViewController.swift */; };
A149147B1BE79FD50060600A /* EditNotificationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A149147A1BE79FD40060600A /* EditNotificationsViewController.swift */; };
A149147F1BE7A5670060600A /* SettingsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A149147E1BE7A5670060600A /* SettingsTableViewCell.swift */; };
A149B3701AEF528C00F19A09 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A149B36F1AEF528C00F19A09 /* SettingsViewController.swift */; };
Expand Down Expand Up @@ -218,7 +219,9 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
9B2488412D030E5100E610E0 /* SignInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInView.swift; sourceTree = "<group>"; };
9B65F2312CFA6418009674A7 /* DeeplinkGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkGenerator.swift; sourceTree = "<group>"; };
9B6F0CE72D2011F00077D37D /* SignInButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInButton.swift; sourceTree = "<group>"; };
9B8CA57C24B120CA009C86C2 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
A10D4E921B07948500A72D29 /* DatapointsTableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatapointsTableView.swift; sourceTree = "<group>"; };
A10DC2DE207BFCBA00FB7B3A /* RemoveHKMetricViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveHKMetricViewController.swift; sourceTree = "<group>"; };
Expand All @@ -233,7 +236,6 @@
A1453B341AED9184006F48DA /* UIColorExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIColorExtension.swift; sourceTree = "<group>"; };
A1453B381AEDA71D006F48DA /* BSLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BSLabel.swift; sourceTree = "<group>"; };
A1453B3C1AEDFA52006F48DA /* CurrentUserManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CurrentUserManager.swift; sourceTree = "<group>"; };
A1453B3E1AEDFCC8006F48DA /* SignInViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignInViewController.swift; sourceTree = "<group>"; };
A149147A1BE79FD40060600A /* EditNotificationsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditNotificationsViewController.swift; sourceTree = "<group>"; };
A149147E1BE7A5670060600A /* SettingsTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsTableViewCell.swift; sourceTree = "<group>"; };
A149B36F1AEF528C00F19A09 /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -478,6 +480,7 @@
A196CB161AE4142E00B90A3E /* BeeSwift */ = {
isa = PBXGroup;
children = (
9B6F0CE72D2011F00077D37D /* SignInButton.swift */,
9B65F2312CFA6418009674A7 /* DeeplinkGenerator.swift */,
A1E618E51E79E01900D8ED93 /* Cells */,
E46071002B43DA7100305DB4 /* Gallery */,
Expand All @@ -493,8 +496,8 @@
A196CB231AE4142F00B90A3E /* Images.xcassets */,
9B8CA57C24B120CA009C86C2 /* LaunchScreen.storyboard */,
E43BEA832A036A9C00FC3A38 /* LogReader.swift */,
A1453B3E1AEDFCC8006F48DA /* SignInViewController.swift */,
E4015D9E2D10DC4D00F58D94 /* SceneDelegate.swift */,
9B2488412D030E5100E610E0 /* SignInView.swift */,
);
path = BeeSwift;
sourceTree = "<group>";
Expand Down Expand Up @@ -1012,12 +1015,12 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1453B3F1AEDFCC8006F48DA /* SignInViewController.swift in Sources */,
A1E618E41E7934C700D8ED93 /* HealthKitConfigTableViewCell.swift in Sources */,
E4B083392932F90400A71564 /* ConfigureHKMetricViewController.swift in Sources */,
E43BEA842A036A9C00FC3A38 /* LogReader.swift in Sources */,
9B65F2322CFA6427009674A7 /* DeeplinkGenerator.swift in Sources */,
A196CB1F1AE4142F00B90A3E /* GalleryViewController.swift in Sources */,
9B6F0CE82D2011F00077D37D /* SignInButton.swift in Sources */,
A1BE73AA1E8B45BF00DEC4DB /* ChooseHKMetricViewController.swift in Sources */,
A149147B1BE79FD50060600A /* EditNotificationsViewController.swift in Sources */,
E4015D9F2D10DC4D00F58D94 /* SceneDelegate.swift in Sources */,
Expand All @@ -1028,6 +1031,7 @@
A10DC2DF207BFCBA00FB7B3A /* RemoveHKMetricViewController.swift in Sources */,
E412DAE12B86A8F70099E483 /* GoalImageView.swift in Sources */,
A1619EA41BEECC1500E14B3A /* EditDefaultNotificationsViewController.swift in Sources */,
9B2488422D030E5100E610E0 /* SignInView.swift in Sources */,
A149B3701AEF528C00F19A09 /* SettingsViewController.swift in Sources */,
E46DC80F2AA58DF20059FDFE /* PullToRefreshHint.swift in Sources */,
A1E618E21E78158700D8ED93 /* HealthKitConfigViewController.swift in Sources */,
Expand Down
42 changes: 32 additions & 10 deletions BeeSwift/Gallery/GalleryViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import SwiftyJSON
import HealthKit
import SafariServices
import OSLog

import SwiftUI
import BeeKit


Expand Down Expand Up @@ -216,13 +216,17 @@ class GalleryViewController: UIViewController, UICollectionViewDelegateFlowLayou
make.right.equalTo(self.view.safeAreaLayoutGuide.snp.rightMargin)
make.bottom.equalTo(self.collectionView!.keyboardLayoutGuide.snp.top)
}

if !ServiceLocator.currentUserManager.signedIn(context: ServiceLocator.persistentContainer.viewContext) {
presentSignInScreen()
}
}

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)

if !ServiceLocator.currentUserManager.signedIn(context: ServiceLocator.persistentContainer.viewContext) {
let signInVC = SignInViewController()
signInVC.modalPresentationStyle = .fullScreen
self.present(signInVC, animated: true, completion: nil)
presentSignInScreen()
} else {
self.updateGoals()
self.fetchGoals()
Expand Down Expand Up @@ -280,15 +284,34 @@ class GalleryViewController: UIViewController, UICollectionViewDelegateFlowLayou
}

@objc func handleSignOut() {
logger.debug("\(#function)")

self.goals = []
self.filteredGoals = []
self.collectionView?.reloadData()
if self.presentedViewController != nil {
if type(of: self.presentedViewController!) == SignInViewController.self { return }

presentSignInScreen()
}


private var isSignInViewBeingPresented: Bool {
guard let presentedViewController else { return false }

return type(of: presentedViewController) == UIHostingController<SignInView>.self
}


private func presentSignInScreen() {
let shallPresentSignIn = presentedViewController == nil || !isSignInViewBeingPresented
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] Isn't presentedViewController == nil duplicative with the rules in isSignInViewBeingPresented?


guard shallPresentSignIn else {
logger.debug("\(#function) - already presenting sign in; not presenting it yet again")
return
}
let signInVC = SignInViewController()
signInVC.modalPresentationStyle = .fullScreen
self.present(signInVC, animated: true, completion: nil)

let hostingVC = UIHostingController(rootView: SignInView())
hostingVC.modalPresentationStyle = .fullScreen
self.present(hostingVC, animated: true)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No action: Medium term I'd like to move to having GalleryView only exist when logged in, and have a higher level view decide if the user is logged in or not, and present it or the sign in screen appropriately. That's definitely not something for this PR though.

}

func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
Expand Down Expand Up @@ -511,4 +534,3 @@ class GalleryViewController: UIViewController, UICollectionViewDelegateFlowLayou
self.fetchGoals()
}
}

34 changes: 34 additions & 0 deletions BeeSwift/SignInButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import SwiftUI

struct SignInButton: View {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Do] It looks like this isn't actually used. Let's either use it or delete it?

@Environment(\.colorScheme) private var colorScheme
let action: () -> Void
let isDisabled: Bool

init(action: @escaping () -> Void, isDisabled: Bool = false) {
self.action = action
self.isDisabled = isDisabled
}

var body: some View {
Button(action: action) {
Label("Sign In", systemImage: "iphone.and.arrow.forward.inward")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] I don't think this image actually renders anywhere?

.font(.system(size: 20))
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.buttonStyle(.bordered)
.foregroundStyle(colorScheme == .dark ? Color.yellow : Color.black)
.overlay {
if colorScheme == .dark {
RoundedRectangle(cornerRadius: 4)
.stroke(Color.yellow, lineWidth: 4)
} else {
RoundedRectangle(cornerRadius: 4)
.stroke(.clear)
}
}
// .cornerRadius(4)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] Either uncomment or delete

krugerk marked this conversation as resolved.
Show resolved Hide resolved
.disabled(isDisabled)
}
}
122 changes: 122 additions & 0 deletions BeeSwift/SignInView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Part of BeeSwift. Copyright Beeminder

import SwiftUI
import BeeKit

struct SignInView: View {
@StateObject private var viewModel = SignInViewModel()
@State private var showingFailedSignInAlert = false
@State private var showingMissingDataAlert = false
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious how you decided what state should be in the ViewModel vs what should live directly in the view?

@Environment(\.dismiss) private var dismiss

var body: some View {
ZStack {
Color.yellow
.opacity(0.8)
.ignoresSafeArea()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Do] I'm glad we're experimenting with design iterations. In this case I've looked at this in light mode and dark mode on device and simulator, and I think the full yellow background is too much. Let's stick with the current white/black background.


HStack(alignment: .center) {

VStack(alignment: .center, spacing: 15) {
Spacer()

Image("website_logo_mid")
.resizable()
.scaledToFit()
.frame(maxWidth: 400)
.padding(.bottom)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Consider] Matching the current design where the logo left/right matches the text inputs in portrait mode, i.e. it's less narrow than the full screen.


VStack(spacing: 15) {
TextField("Email or username", text: $viewModel.email)
.textFieldStyle(RoundedBorderTextFieldStyle())
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.submitLabel(.next)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At present the placeholder text is too low contrast to be readable in dark mode. This might be resolved by changing the background though.


SecureField("Password", text: $viewModel.password)
.textFieldStyle(RoundedBorderTextFieldStyle())
.textInputAutocapitalization(.never)
.submitLabel(.done)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Consider] I confirmed using a password manager will auto-fill these fields. Can we make it auto-trigger the login after filling too?


SignInButton(action: signIn,
isDisabled: viewModel.isLoading)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 40)

Spacer()
Spacer()
Spacer()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] This is a surprising pattern. I'm guessing it's to result in the overall login being positioned vertically offset from center. I wonder if there is a way to do that directly via e.g. different weights for top and bottom padding? If not, let's add a comment.

}
.padding()

}
.alert("Could not sign in", isPresented: $showingFailedSignInAlert) {
Button("OK", role: .cancel) { }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do these interact? Does clicking the OK button clear the variable?

} message: {
Text("Invalid credentials")
}
.alert("Incomplete Account Details", isPresented: $showingMissingDataAlert) {
Button("OK", role: .cancel) { }
} message: {
Text("Username and Password are required")
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name(CurrentUserManager.failedSignInNotificationName))) { _ in
viewModel.isLoading = false
showingFailedSignInAlert = true
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name(CurrentUserManager.signedInNotificationName))) { _ in
viewModel.isLoading = false
dismiss()
}
}
.overlay {
if viewModel.isLoading {
ProgressView()
.scaleEffect(1.5)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.opacity(0.2))
}
}
}

private func signIn() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have guessed this method is allowed to be async (and thus not need the task)

guard !viewModel.email.trimmingCharacters(in: .whitespaces).isEmpty,
!viewModel.password.isEmpty else {
showingMissingDataAlert = true
return
}

Task {
await viewModel.signIn()
}
}
}

@MainActor
class SignInViewModel: ObservableObject {
@Published var email = ""
@Published var password = ""
@Published var isLoading = false

func signIn() async {
isLoading = true

do {
try await Task.sleep(nanoseconds: 3_000_000_000)
} catch {
print("canceled early")
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is code you added to test the loading state but shouldn't be merged?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The min process time was intentional so as to provide for a better user experience, actually. When tapping the button to trigger a sign in, it is usually a very quick process and one hardly has a chance to notice the progress indicator or the network activity indicator. If we are to show these, then they should be shown long enough for the user to have had a chance to recognize them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plus when the user logs in, the endpoint returns a user object completed with a list of goal names. It could be that the app then presents a loading state of the gallery with the data it has while it continues fetching each goal and filling in the cells.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could maybe get behind this argument. However, I also log in/out a lot as part of testing the app, and waiting 3 seconds every time would be a significant frustration.

I'd be open to having the login screen continue to show in the loading state until all goals are loaded into the gallery controller. But no explicit additional delays.


await ServiceLocator.currentUserManager.signInWithEmail(
email.trimmingCharacters(in: .whitespaces),
password: password
)
}
}

#Preview {
SignInView()
}
Loading
Loading