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

Add shared state sample #19

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
56 changes: 56 additions & 0 deletions CombineFeedback.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@
58C6E5E022A9F027005A9685 /* Movies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6E5DF22A9F027005A9685 /* Movies.swift */; };
58C6E5E722AB14DB005A9685 /* AsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6E5E622AB14DB005A9685 /* AsyncImage.swift */; };
7415E15122E1B4F000117DA3 /* TestScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74A3948222E0B7E20061CE51 /* TestScheduler.swift */; };
C3B78C7D2371170D00D91F95 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B78C752371170D00D91F95 /* RegistrationView.swift */; };
C3B78C7E2371170D00D91F95 /* RegistrationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B78C762371170D00D91F95 /* RegistrationViewModel.swift */; };
C3B78C7F2371170D00D91F95 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B78C782371170D00D91F95 /* LoginView.swift */; };
C3B78C802371170D00D91F95 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B78C792371170D00D91F95 /* LoginViewModel.swift */; };
C3B78C812371170D00D91F95 /* UserAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B78C7B2371170D00D91F95 /* UserAccountViewModel.swift */; };
C3B78C822371170D00D91F95 /* UserAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B78C7C2371170D00D91F95 /* UserAccountView.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -159,6 +165,12 @@
58C6E5E622AB14DB005A9685 /* AsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncImage.swift; sourceTree = "<group>"; };
742D7D6522B02A7100A97AF3 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = "<group>"; };
74A3948222E0B7E20061CE51 /* TestScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestScheduler.swift; sourceTree = "<group>"; };
C3B78C752371170D00D91F95 /* RegistrationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = "<group>"; };
C3B78C762371170D00D91F95 /* RegistrationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RegistrationViewModel.swift; sourceTree = "<group>"; };
C3B78C782371170D00D91F95 /* LoginView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
C3B78C792371170D00D91F95 /* LoginViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = "<group>"; };
C3B78C7B2371170D00D91F95 /* UserAccountViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserAccountViewModel.swift; sourceTree = "<group>"; };
C3B78C7C2371170D00D91F95 /* UserAccountView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserAccountView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -281,6 +293,7 @@
5800FFB722A8CDA5005A860B /* Example */ = {
isa = PBXGroup;
children = (
C3B78C732371170D00D91F95 /* Account */,
583971BD22ADB9DF00139CC0 /* Helpers */,
58C6E5E522AB149D005A9685 /* Views */,
58C6E5DE22A9EFDF005A9685 /* CounterExample */,
Expand Down Expand Up @@ -393,6 +406,43 @@
path = Tests;
sourceTree = "<group>";
};
C3B78C732371170D00D91F95 /* Account */ = {
isa = PBXGroup;
children = (
C3B78C742371170D00D91F95 /* RegistrationView */,
C3B78C772371170D00D91F95 /* LoginView */,
C3B78C7A2371170D00D91F95 /* UserAccountView */,
);
path = Account;
sourceTree = "<group>";
};
C3B78C742371170D00D91F95 /* RegistrationView */ = {
isa = PBXGroup;
children = (
C3B78C752371170D00D91F95 /* RegistrationView.swift */,
C3B78C762371170D00D91F95 /* RegistrationViewModel.swift */,
);
path = RegistrationView;
sourceTree = "<group>";
};
C3B78C772371170D00D91F95 /* LoginView */ = {
isa = PBXGroup;
children = (
C3B78C782371170D00D91F95 /* LoginView.swift */,
C3B78C792371170D00D91F95 /* LoginViewModel.swift */,
);
path = LoginView;
sourceTree = "<group>";
};
C3B78C7A2371170D00D91F95 /* UserAccountView */ = {
isa = PBXGroup;
children = (
C3B78C7B2371170D00D91F95 /* UserAccountViewModel.swift */,
C3B78C7C2371170D00D91F95 /* UserAccountView.swift */,
);
path = UserAccountView;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXHeadersBuildPhase section */
Expand Down Expand Up @@ -658,14 +708,20 @@
buildActionMask = 2147483647;
files = (
25C57B2C22BC2C33007CB4D6 /* Activity.swift in Sources */,
C3B78C7E2371170D00D91F95 /* RegistrationViewModel.swift in Sources */,
58C6E5E022A9F027005A9685 /* Movies.swift in Sources */,
C3B78C822371170D00D91F95 /* UserAccountView.swift in Sources */,
252BF08422BAE05700BC4265 /* SignIn.swift in Sources */,
C3B78C802371170D00D91F95 /* LoginViewModel.swift in Sources */,
5800FFB922A8CDA5005A860B /* AppDelegate.swift in Sources */,
5800FFBB22A8CDA5005A860B /* SceneDelegate.swift in Sources */,
C3B78C812371170D00D91F95 /* UserAccountViewModel.swift in Sources */,
C3B78C7F2371170D00D91F95 /* LoginView.swift in Sources */,
583971C322ADBA9900139CC0 /* PublisherExtensions.swift in Sources */,
58C6E5E722AB14DB005A9685 /* AsyncImage.swift in Sources */,
5800FFBD22A8CDA5005A860B /* Counter.swift in Sources */,
25F23C2922CA984E00894863 /* TrafficLight.swift in Sources */,
C3B78C7D2371170D00D91F95 /* RegistrationView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
53 changes: 53 additions & 0 deletions Example/Account/LoginView/LoginView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Foundation
import SwiftUI
import Combine
import CombineFeedback
import CombineFeedbackUI

public struct LoginView: View {
public typealias State = LoginViewModel.State
public typealias Event = LoginViewModel.Event

private let context: Context<State, Event>

public init(context: Context<State, Event>) {
self.context = context
}

private var submitButton: some View {
Button("Login") { self.context.send(event: .login) }
}

private var emailSection: some View {
Section(header: Text("Email"), footer: Text(self.context.invalidEmailMessage)) {
TextField("Email", text: self.context.binding(for: \.email))
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.autocapitalization(.none)
}
}

private var passwordSection: some View {
Section(header: Text("Password"), footer: Text(self.context.invalidPasswordMessage)) {
SecureField("Password", text: self.context.binding(for: \.password))
.textContentType(.newPassword)
}
}

private func welcomeAlert(alert: Alert) -> SwiftUI.Alert {
SwiftUI.Alert(title: Text(alert.title), message: Text(alert.message), dismissButton: .default(Text("OK")) {
self.context.send(event: .dismiss)
})
}

public var body: some View {
Form {
self.emailSection
self.passwordSection
self.submitButton
}
.alert(item: .constant(self.context.alertMessage), content: self.welcomeAlert(alert:))
.navigationBarItems(trailing: Activity(isAnimating: .constant(self.context.status.isLoading), style: .medium))
.navigationBarTitle(Text("Account"))
}
}
111 changes: 111 additions & 0 deletions Example/Account/LoginView/LoginViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import Foundation
import Combine
import CombineFeedback
import CombineFeedbackUI

extension LoginViewModel {
public struct State {
var email = ""
var password = ""
var triedToSubmitOnce = false
var status = Status.idle
var loggedAccount: LoggedAccount?
var dismissed: Bool = false

public init() {
}

var alertMessage: Alert? {
guard let loggedAccount = loggedAccount else {
return nil
}
return Alert(title: "Welcome back", message: "\(loggedAccount.firstName)")
}

var invalidEmailMessage: String {
invalidEmail ? "Invalid E-mail" : ""
}

var invalidPasswordMessage: String {
invalidPassword ? "Invalid Password" : ""
}

var valid: Bool {
invalidEmail == false && invalidPassword == false
}

private var invalidEmail: Bool {
triedToSubmitOnce && !email.validEmail
}

private var invalidPassword: Bool {
triedToSubmitOnce && password.count <= 5
}
}

public enum Status {
case idle
case loading

var isLoading: Bool {
if case .loading = self {
return true
}
return false
}
}

public enum Event {
case login
case didSignin(LoggedAccount)
case dismiss
}
}

public final class LoginViewModel: ViewModel<LoginViewModel.State, LoginViewModel.Event> {

init(state: State = State()) {
super.init(
initial: state,
feedbacks: [LoginViewModel.whenSigning()],
scheduler: RunLoop.main,
reducer: LoginViewModel.reducer(state:event:)
)
}

private static func whenSigning() -> Feedback<State, Event> {
Feedback(predicate: { $0.status.isLoading }, effects: { (state: State) in
Just(())
.delay(for: 2, scheduler: RunLoop.main)
.map { _ in
LoggedAccount(email: state.email,
password: state.password,
firstName: "Diego",
lastName: "Chohfi")
}
.map(Event.didSignin)
.eraseToAnyPublisher()
})
}

private static func reducer(state: State, event: Event) -> State {
switch event {
case .login:
var state = state
state.triedToSubmitOnce = true
if state.valid {
state.status = .loading
}
return state
case let .didSignin(account):
var state = state
state.status = .idle
state.loggedAccount = account
return state
case .dismiss:
var state = state
state.dismissed = true
return state
}
}
}
79 changes: 79 additions & 0 deletions Example/Account/RegistrationView/RegistrationView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import Foundation
import SwiftUI
import Combine
import CombineFeedback
import CombineFeedbackUI

public struct RegistrationView: View {
public typealias State = RegistrationViewModel.State
public typealias Event = RegistrationViewModel.Event

private let context: Context<State, Event>
private let loginViewModel: LoginViewModel

public init(context: Context<State, Event>, loginViewModel: LoginViewModel) {
self.context = context
self.loginViewModel = loginViewModel
}

private var submitButton: some View {
Button("Create Account", action: { self.context.send(event: .register) })
}

private var nameSection: some View {
Section(header: Text("You")) {
HStack {
TextField("First name", text: self.context.binding(for: \.firstName))
TextField("Last name", text: self.context.binding(for: \.lastName))
}
}
}

private var emailSection: some View {
Section(header: Text("Email"), footer: Text(self.context.invalidEmailMessage)) {
TextField("Email", text: self.context.binding(for: \.email))
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.autocapitalization(.none)
}
}

private var passwordSection: some View {
Section(header: Text("Password"), footer: Text(self.context.invalidPasswordMessage)) {
SecureField("Password", text: self.context.binding(for: \.password))
.textContentType(.newPassword)
}
}

private var navigateToSignin: some View {
HStack {
Activity(isAnimating: .constant(self.context.status.isLoading), style: .medium)
NavigationLink("Sign In", destination: Widget(viewModel: self.loginViewModel, render: LoginView.init))
}
}

private var dismissButton: some View {
Button("Dismiss") { self.context.send(event: .dismiss) }
}

private func welcomeAlert(alert: Alert) -> SwiftUI.Alert {
SwiftUI.Alert(title: Text(alert.title), message: Text(alert.message), dismissButton: .default(Text("OK")) {
self.context.send(event: .dismiss)
})
}

public var body: some View {
NavigationView {
Form {
self.nameSection
self.emailSection
self.passwordSection
self.submitButton
}
.disabled(self.context.status.isLoading)
.alert(item: .constant(self.context.alertMessage), content: self.welcomeAlert(alert:))
.navigationBarTitle(Text("Account"))
.navigationBarItems(leading: self.dismissButton, trailing: self.navigateToSignin)
}
}
}
Loading