Skip to content

Commit

Permalink
[NTV-613] Change Email View SwiftUI (#1803)
Browse files Browse the repository at this point in the history
* semi-built rendering of change email view

* removed playground, will move swiftui code to ChangeEmailView

* base view in SwiftUI for ChangeEmailViewController

Still missing navigation bar content, but it is launching inside UIHostingViewController, so that's good news!

* Adjusted VStack to have zero vertical spacing so seperator gets anchored to the bottom of the vstack.

* styling applied to email, new email and password fields.

* cleaned up modifier logic based on field type using cutom modifiers.

* corrected return/done button logic, prevent submission until character limit on password is reached.

* Added warning label

* warningMessage updated for unverified email and email undeliverable.

* added resend verification button and fixed spacing for button and warning label.

* attempting to add message banner view controller...still tinkering with uiviewcontrollerrepresentable

* separated some view modifier code that can be considered common.

* wrote the uiviewcontrollerrepresentable and wrapper view. Needs a bit of tinkering to reconfigure the ChangelEmailView to contain an instance of MessageBannerView.

* message banner view displaying. Some UI corrections required.

* Recreated a non-animated MessageBannerView. A few things still needs mapping:

- voice over and message banner view hide, banner message accessibility label. Also add animation and size change on orientation change. Also double check dynamic type and voice over when complete.

* minor accessor scope tweak

* completed message banner view

* connecting the view model to the view is not quite working. Will have to use Combine instead of ReactiveSwift so its more complicated that first planned.

* found a way to integrate existing view model into change email view.

* completed functionality up to saving new email successfully

TODO:
1. Ensure when save is complete, we update the save button.
2. Ensure both textfields are disabled while saving (ie. loading state == true)
3. After sucessful save then clear the new email and password fields

* completing a save and an errored save are working but subsequent saves/errors don't show banner message.

- subsequent saves not working because save is not enabled
- subsequent saves not showing erorr/success banner message.
- validate that verification email view is hidden after email is verified.
- fix backspace issue triggering save.

* added subsequent saves and banner show

* adjusted the banner to be a little more space conscious with the test.

Banner view looks a bit over spaced when on larger screens and fits most text in when smaller screens go landscape. This can be tightened in the future.

* deleted some unused files and fixed a production bug related to unseen current email.

* corrected message text
  • Loading branch information
msadoon committed Mar 27, 2023
1 parent 284ef75 commit ad7b971
Show file tree
Hide file tree
Showing 9 changed files with 752 additions and 1 deletion.
235 changes: 235 additions & 0 deletions Kickstarter-iOS/Features/ChangeEmail/Controller/ChangeEmailView.swift
@@ -0,0 +1,235 @@
import Combine
import Library
import SwiftUI

enum FocusField {
case newEmail
case currentPassword
}

@available(iOS 15.0, *)
struct ChangeEmailView: View {
@SwiftUI.Environment(\.defaultMinListRowHeight) var minListRow
@FocusState private var focusField: FocusField?
private let contentPadding = 12.0
@ObservedObject private var reactiveViewModel = ChangeEmailViewModel_SwiftUIIntegrationTest()
@State private var retrievedEmailText = ""
@State private var newEmailText = ""
@State private var newPasswordText = ""
@State private var saveEnabled = false
@State private var saveTriggered = false
@State private var showLoading = false
@State private var showBannerMessage = false
@State private var bannerMessage: MessageBannerViewViewModel?

var body: some View {
GeometryReader { proxy in
List {
Color(.ksr_support_100)
.frame(maxWidth: .infinity, maxHeight: minListRow, alignment: .center)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())

VStack(alignment: .center, spacing: 0) {
InputFieldView(
titleText: Strings.Current_email(),
secureField: false,
placeholderText: "",
contentPadding: contentPadding,
valueText: $retrievedEmailText
)
.currentEmail()
.onReceive(reactiveViewModel.retrievedEmailText) { newValue in
retrievedEmailText = newValue
}

Color(.ksr_cell_separator).frame(maxWidth: .infinity, maxHeight: 1)
}
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())

if !reactiveViewModel.hideMessageLabel {
warningLabel(
text: reactiveViewModel.warningMessageWithAlert.0,
reactiveViewModel.warningMessageWithAlert.1
)
.frame(maxWidth: .infinity, maxHeight: minListRow, alignment: .leading)
.background(Color(.ksr_support_100))
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
}

if !reactiveViewModel.hideVerifyView {
VStack(alignment: .leading, spacing: 0) {
Button(reactiveViewModel.verifyEmailButtonTitle) {
reactiveViewModel.inputs.resendVerificationEmailButtonTapped()
}
.font(Font(UIFont.ksr_body()))
.foregroundColor(Color(.ksr_create_700))
.padding(contentPadding)
.disabled(showLoading)

Color(.ksr_cell_separator).frame(maxWidth: .infinity, maxHeight: 1)
}
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
}

Color(.ksr_support_100)
.frame(maxWidth: .infinity, maxHeight: minListRow, alignment: .center)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())

VStack(alignment: .center, spacing: 0) {
InputFieldView(
titleText: Strings.New_email(),
secureField: false,
placeholderText: Strings.login_placeholder_email(),
contentPadding: contentPadding,
valueText: $newEmailText
)
.onReceive(reactiveViewModel.resetEditableText) { newValue in
if newValue {
newEmailText = ""
}
}
.onChange(of: newEmailText) { newValue in
reactiveViewModel.newEmailText.send(newValue)
}
.newEmail(editable: !showLoading)
.focused($focusField, equals: .newEmail)
.onSubmit {
focusField = .currentPassword
}

InputFieldView(
titleText: Strings.Current_password(),
secureField: true,
placeholderText: Strings.login_placeholder_password(),
contentPadding: contentPadding,
valueText: $newPasswordText
)
.onChange(of: newPasswordText) { newValue in
reactiveViewModel.currentPasswordText.send(newValue)
}
.currentPassword(editable: !showLoading)
.focused($focusField, equals: .currentPassword)
.onReceive(reactiveViewModel.resetEditableText) { resetFlag in
if resetFlag {
newPasswordText = ""
}
}
// FIXME: So "Done" on keyboard doesn't trigger Save --> in the future we might want to add this (was in the old `ChangeEmailViewController`)

Color(.ksr_cell_separator).frame(maxWidth: .infinity, maxHeight: 1)
}
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
}
.navigationTitle(Strings.Change_email())
.background(Color(.ksr_support_100))
.listStyle(.plain)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
LoadingBarButtonItem(
saveEnabled: $saveEnabled,
saveTriggered: $saveTriggered,
showLoading: $showLoading,
titleText: Strings.Save()
)
.onReceive(reactiveViewModel.saveButtonEnabled) { newValue in
saveEnabled = newValue
}
.onReceive(reactiveViewModel.resetEditableText) { newValue in
showLoading = !newValue
}
.onChange(of: saveTriggered) { newValue in
focusField = nil
reactiveViewModel.saveTriggered.send(newValue)
}
}
}
.overlay(alignment: .bottom) {
MessageBannerView(viewModel: $bannerMessage)
.frame(
minWidth: proxy.size.width,
idealWidth: proxy.size.width,
maxHeight: proxy.size.height / 5,
alignment: .bottom
)
.animation(.easeInOut)
}
.onReceive(reactiveViewModel.bannerMessage) { newValue in
bannerMessage = newValue
}
.onAppear {
reactiveViewModel.inputs.viewDidLoad()
}
}
}

private struct InputFieldView: View {
var titleText: String
var secureField: Bool
var placeholderText: String
var contentPadding: CGFloat
var valueText: Binding<String>

var body: some View {
HStack {
Text(titleText)
.frame(
maxWidth: .infinity,
alignment: .leading
)
.font(Font(UIFont.ksr_body()))
.foregroundColor(Color(.ksr_support_700))
Spacer()

InputFieldUserInputView(
secureField: secureField,
placeholderText: placeholderText,
valueText: valueText
)
}
.padding(contentPadding)
.accessibilityElement(children: .combine)
.accessibilityLabel(titleText)
}
}

private struct InputFieldUserInputView: View {
var secureField: Bool
var placeholderText: String
var valueText: Binding<String>

var body: some View {
if secureField {
SecureField(
"",
text: valueText,
prompt: Text(placeholderText).foregroundColor(Color(.ksr_support_400))
)
} else {
TextField(
"",
text: valueText,
prompt:
Text(placeholderText).foregroundColor(Color(.ksr_support_400))
)
}
}
}

@ViewBuilder
private func warningLabel(text: String, _ alert: Bool) -> some View {
let textColor = alert ? Color(.ksr_alert) : Color(.ksr_support_400)

Label(text, systemImage: "exclamationmark.triangle.fill")
.labelStyle(.titleOnly)
.font(Font(UIFont.ksr_body(size: 13)))
.lineLimit(nil)
.padding([.leading, .trailing], self.contentPadding)
.foregroundColor(textColor)
}
}
@@ -0,0 +1,33 @@
import Library
import SwiftUI

@available(iOS 15.0, *)
struct MessageBannerView: View {
@Binding var viewModel: MessageBannerViewViewModel?

var body: some View {
if let vm = viewModel {
ZStack {
RoundedRectangle(cornerRadius: 4)
.foregroundColor(vm.bannerBackgroundColor)
Label(vm.bannerMessage, image: vm.iconImageName)
.font(Font(UIFont.ksr_subhead()))
.foregroundColor(vm.messageTextColor)
.lineLimit(3)
.multilineTextAlignment(vm.messageTextAlignment)
.padding(EdgeInsets(top: 5, leading: 10, bottom: 5, trailing: 10))
}
.accessibilityElement()
.accessibilityLabel(vm.bannerMessageAccessibilityLabel)
.padding()
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
viewModel = nil
}
}
.onTapGesture {
viewModel = nil
}
}
}
}
Expand Up @@ -2,6 +2,7 @@ import KsApi
import Library
import Prelude
import ReactiveSwift
import SwiftUI
import UIKit

final class SettingsAccountViewController: UIViewController, MessageBannerViewControllerPresenting {
Expand Down Expand Up @@ -188,7 +189,13 @@ extension SettingsAccountViewController {
case .createPassword:
return CreatePasswordViewController.instantiate()
case .changeEmail:
return ChangeEmailViewController.instantiate()
if #available(iOS 15, *) {
let changeEmailView = ChangeEmailView()

return UIHostingController(rootView: changeEmailView)
} else {
return ChangeEmailViewController.instantiate()
}
case .changePassword:
return ChangePasswordViewController.instantiate()
case .paymentMethods:
Expand Down
33 changes: 33 additions & 0 deletions Kickstarter-iOS/SharedViews/LoadingBarButtonItem.swift
@@ -0,0 +1,33 @@
import Library
import SwiftUI

struct LoadingBarButtonItem: View {
@Binding var saveEnabled: Bool
@Binding var saveTriggered: Bool
@Binding var showLoading: Bool
@State var titleText: String

var body: some View {
let buttonColor = $saveEnabled.wrappedValue ? Color(.ksr_create_700) : Color(.ksr_create_300)

HStack {
if !showLoading {
Button(titleText) {
showLoading = true
saveTriggered = true
}
.font(Font(UIFont.systemFont(ofSize: 17)))
.foregroundColor(buttonColor)
.disabled(!$saveEnabled.wrappedValue)
} else {
ProgressView()
.foregroundColor(Color(.ksr_support_700))
.onDisappear {
saveTriggered = false
}
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel(titleText)
}
}

0 comments on commit ad7b971

Please sign in to comment.