Skip to content
Snippets Groups Projects
Commit c039bb0a authored by Dariusz Rybicki's avatar Dariusz Rybicki
Browse files

Migrate ChatFeature to ReducerProtocol

parent 90bdd96f
No related branches found
No related tags found
2 merge requests!126Migrate example app to ComposableArchitecture's ReducerProtocol,!102Release 1.0.0
This commit is part of merge request !102. Comments created here will be created in the context of that merge request.
import AppCore
import Combine
import ComposableArchitecture
import Foundation
import XCTestDynamicOverlay
import XXClient
import XXMessengerClient
import XXModels
public struct ChatComponent: ReducerProtocol {
public struct State: Equatable, Identifiable {
public enum ID: Equatable, Hashable {
case contact(Data)
}
public struct Message: Equatable, Identifiable {
public init(
id: Int64,
date: Date,
senderId: Data,
text: String,
status: XXModels.Message.Status,
fileTransfer: XXModels.FileTransfer? = nil
) {
self.id = id
self.date = date
self.senderId = senderId
self.text = text
self.status = status
self.fileTransfer = fileTransfer
}
public var id: Int64
public var date: Date
public var senderId: Data
public var text: String
public var status: XXModels.Message.Status
public var fileTransfer: XXModels.FileTransfer?
}
public init(
id: ID,
myContactId: Data? = nil,
messages: IdentifiedArrayOf<Message> = [],
failure: String? = nil,
sendFailure: String? = nil,
text: String = ""
) {
self.id = id
self.myContactId = myContactId
self.messages = messages
self.failure = failure
self.sendFailure = sendFailure
self.text = text
}
public var id: ID
public var myContactId: Data?
public var messages: IdentifiedArrayOf<Message>
public var failure: String?
public var sendFailure: String?
@BindableState public var text: String
}
public enum Action: Equatable, BindableAction {
case start
case didFetchMessages(IdentifiedArrayOf<State.Message>)
case sendTapped
case sendFailed(String)
case imagePicked(Data)
case dismissSendFailureTapped
case binding(BindingAction<State>)
}
public init() {}
@Dependency(\.app.messenger) var messenger: Messenger
@Dependency(\.app.dbManager.getDB) var db: DBManagerGetDB
@Dependency(\.app.sendMessage) var sendMessage: SendMessage
@Dependency(\.app.sendImage) var sendImage: SendImage
@Dependency(\.app.mainQueue) var mainQueue: AnySchedulerOf<DispatchQueue>
@Dependency(\.app.bgQueue) var bgQueue: AnySchedulerOf<DispatchQueue>
public var body: some ReducerProtocol<State, Action> {
BindingReducer()
Reduce { state, action in
enum FetchEffectId {}
switch action {
case .start:
state.failure = nil
do {
let myContactId = try messenger.e2e.tryGet().getContact().getId()
state.myContactId = myContactId
let queryChat: XXModels.Message.Query.Chat
let receivedFileTransfersQuery: XXModels.FileTransfer.Query
let sentFileTransfersQuery: XXModels.FileTransfer.Query
switch state.id {
case .contact(let contactId):
queryChat = .direct(myContactId, contactId)
receivedFileTransfersQuery = .init(
contactId: contactId,
isIncoming: true
)
sentFileTransfersQuery = .init(
contactId: myContactId,
isIncoming: false
)
}
let messagesQuery = XXModels.Message.Query(chat: queryChat)
return Publishers.CombineLatest3(
try db().fetchMessagesPublisher(messagesQuery),
try db().fetchFileTransfersPublisher(receivedFileTransfersQuery),
try db().fetchFileTransfersPublisher(sentFileTransfersQuery)
)
.map { messages, receivedFileTransfers, sentFileTransfers in
(messages, receivedFileTransfers + sentFileTransfers)
}
.assertNoFailure()
.map { messages, fileTransfers in
messages.compactMap { message in
guard let id = message.id else { return nil }
return State.Message(
id: id,
date: message.date,
senderId: message.senderId,
text: message.text,
status: message.status,
fileTransfer: fileTransfers.first { $0.id == message.fileTransferId }
)
}
}
.removeDuplicates()
.map { IdentifiedArrayOf<State.Message>(uniqueElements: $0) }
.map(Action.didFetchMessages)
.subscribe(on: bgQueue)
.receive(on: mainQueue)
.eraseToEffect()
.cancellable(id: FetchEffectId.self, cancelInFlight: true)
} catch {
state.failure = error.localizedDescription
return .none
}
case .didFetchMessages(let messages):
state.messages = messages
return .none
case .sendTapped:
let text = state.text
let chatId = state.id
state.text = ""
return Effect.run { subscriber in
switch chatId {
case .contact(let recipientId):
sendMessage(
text: text,
to: recipientId,
onError: { error in
subscriber.send(.sendFailed(error.localizedDescription))
},
completion: {
subscriber.send(completion: .finished)
}
)
}
return AnyCancellable {}
}
.subscribe(on: bgQueue)
.receive(on: mainQueue)
.eraseToEffect()
case .sendFailed(let failure):
state.sendFailure = failure
return .none
case .imagePicked(let data):
let chatId = state.id
return Effect.run { subscriber in
switch chatId {
case .contact(let recipientId):
sendImage(
data,
to: recipientId,
onError: { error in
subscriber.send(.sendFailed(error.localizedDescription))
},
completion: {
subscriber.send(completion: .finished)
}
)
}
return AnyCancellable {}
}
.subscribe(on: bgQueue)
.receive(on: mainQueue)
.eraseToEffect()
case .dismissSendFailureTapped:
state.sendFailure = nil
return .none
case .binding(_):
return .none
}
}
}
}
import AppCore
import Combine
import ComposableArchitecture
import Foundation
import XCTestDynamicOverlay
import XXClient
import XXMessengerClient
import XXModels
public struct ChatState: Equatable, Identifiable {
public enum ID: Equatable, Hashable {
case contact(Data)
}
public struct Message: Equatable, Identifiable {
public init(
id: Int64,
date: Date,
senderId: Data,
text: String,
status: XXModels.Message.Status,
fileTransfer: XXModels.FileTransfer? = nil
) {
self.id = id
self.date = date
self.senderId = senderId
self.text = text
self.status = status
self.fileTransfer = fileTransfer
}
public var id: Int64
public var date: Date
public var senderId: Data
public var text: String
public var status: XXModels.Message.Status
public var fileTransfer: XXModels.FileTransfer?
}
public init(
id: ID,
myContactId: Data? = nil,
messages: IdentifiedArrayOf<Message> = [],
failure: String? = nil,
sendFailure: String? = nil,
text: String = ""
) {
self.id = id
self.myContactId = myContactId
self.messages = messages
self.failure = failure
self.sendFailure = sendFailure
self.text = text
}
public var id: ID
public var myContactId: Data?
public var messages: IdentifiedArrayOf<Message>
public var failure: String?
public var sendFailure: String?
@BindableState public var text: String
}
public enum ChatAction: Equatable, BindableAction {
case start
case didFetchMessages(IdentifiedArrayOf<ChatState.Message>)
case sendTapped
case sendFailed(String)
case imagePicked(Data)
case dismissSendFailureTapped
case binding(BindingAction<ChatState>)
}
public struct ChatEnvironment {
public init(
messenger: Messenger,
db: DBManagerGetDB,
sendMessage: SendMessage,
sendImage: SendImage,
mainQueue: AnySchedulerOf<DispatchQueue>,
bgQueue: AnySchedulerOf<DispatchQueue>
) {
self.messenger = messenger
self.db = db
self.sendMessage = sendMessage
self.sendImage = sendImage
self.mainQueue = mainQueue
self.bgQueue = bgQueue
}
public var messenger: Messenger
public var db: DBManagerGetDB
public var sendMessage: SendMessage
public var sendImage: SendImage
public var mainQueue: AnySchedulerOf<DispatchQueue>
public var bgQueue: AnySchedulerOf<DispatchQueue>
}
#if DEBUG
extension ChatEnvironment {
public static let unimplemented = ChatEnvironment(
messenger: .unimplemented,
db: .unimplemented,
sendMessage: .unimplemented,
sendImage: .unimplemented,
mainQueue: .unimplemented,
bgQueue: .unimplemented
)
}
#endif
public let chatReducer = Reducer<ChatState, ChatAction, ChatEnvironment>
{ state, action, env in
enum FetchEffectId {}
switch action {
case .start:
state.failure = nil
do {
let myContactId = try env.messenger.e2e.tryGet().getContact().getId()
state.myContactId = myContactId
let queryChat: XXModels.Message.Query.Chat
let receivedFileTransfersQuery: XXModels.FileTransfer.Query
let sentFileTransfersQuery: XXModels.FileTransfer.Query
switch state.id {
case .contact(let contactId):
queryChat = .direct(myContactId, contactId)
receivedFileTransfersQuery = .init(
contactId: contactId,
isIncoming: true
)
sentFileTransfersQuery = .init(
contactId: myContactId,
isIncoming: false
)
}
let messagesQuery = XXModels.Message.Query(chat: queryChat)
return Publishers.CombineLatest3(
try env.db().fetchMessagesPublisher(messagesQuery),
try env.db().fetchFileTransfersPublisher(receivedFileTransfersQuery),
try env.db().fetchFileTransfersPublisher(sentFileTransfersQuery)
)
.map { messages, receivedFileTransfers, sentFileTransfers in
(messages, receivedFileTransfers + sentFileTransfers)
}
.assertNoFailure()
.map { messages, fileTransfers in
messages.compactMap { message in
guard let id = message.id else { return nil }
return ChatState.Message(
id: id,
date: message.date,
senderId: message.senderId,
text: message.text,
status: message.status,
fileTransfer: fileTransfers.first { $0.id == message.fileTransferId }
)
}
}
.removeDuplicates()
.map { IdentifiedArrayOf<ChatState.Message>(uniqueElements: $0) }
.map(ChatAction.didFetchMessages)
.subscribe(on: env.bgQueue)
.receive(on: env.mainQueue)
.eraseToEffect()
.cancellable(id: FetchEffectId.self, cancelInFlight: true)
} catch {
state.failure = error.localizedDescription
return .none
}
case .didFetchMessages(let messages):
state.messages = messages
return .none
case .sendTapped:
let text = state.text
let chatId = state.id
state.text = ""
return Effect.run { subscriber in
switch chatId {
case .contact(let recipientId):
env.sendMessage(
text: text,
to: recipientId,
onError: { error in
subscriber.send(.sendFailed(error.localizedDescription))
},
completion: {
subscriber.send(completion: .finished)
}
)
}
return AnyCancellable {}
}
.subscribe(on: env.bgQueue)
.receive(on: env.mainQueue)
.eraseToEffect()
case .sendFailed(let failure):
state.sendFailure = failure
return .none
case .imagePicked(let data):
let chatId = state.id
return Effect.run { subscriber in
switch chatId {
case .contact(let recipientId):
env.sendImage(
data,
to: recipientId,
onError: { error in
subscriber.send(.sendFailed(error.localizedDescription))
},
completion: {
subscriber.send(completion: .finished)
}
)
}
return AnyCancellable {}
}
.subscribe(on: env.bgQueue)
.receive(on: env.mainQueue)
.eraseToEffect()
case .dismissSendFailureTapped:
state.sendFailure = nil
return .none
case .binding(_):
return .none
}
}
.binding()
...@@ -3,21 +3,21 @@ import ComposableArchitecture ...@@ -3,21 +3,21 @@ import ComposableArchitecture
import SwiftUI import SwiftUI
public struct ChatView: View { public struct ChatView: View {
public init(store: Store<ChatState, ChatAction>) { public init(store: StoreOf<ChatComponent>) {
self.store = store self.store = store
} }
let store: Store<ChatState, ChatAction> let store: StoreOf<ChatComponent>
@State var isPresentingImagePicker = false @State var isPresentingImagePicker = false
struct ViewState: Equatable { struct ViewState: Equatable {
var myContactId: Data? var myContactId: Data?
var messages: IdentifiedArrayOf<ChatState.Message> var messages: IdentifiedArrayOf<ChatComponent.State.Message>
var failure: String? var failure: String?
var sendFailure: String? var sendFailure: String?
var text: String var text: String
init(state: ChatState) { init(state: ChatComponent.State) {
myContactId = state.myContactId myContactId = state.myContactId
messages = state.messages messages = state.messages
failure = state.failure failure = state.failure
...@@ -84,7 +84,7 @@ public struct ChatView: View { ...@@ -84,7 +84,7 @@ public struct ChatView: View {
HStack { HStack {
TextField("Text", text: viewStore.binding( TextField("Text", text: viewStore.binding(
get: \.text, get: \.text,
send: { ChatAction.set(\.$text, $0) } send: { ChatComponent.Action.set(\.$text, $0) }
)) ))
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
...@@ -122,7 +122,7 @@ public struct ChatView: View { ...@@ -122,7 +122,7 @@ public struct ChatView: View {
} }
struct MessageView: View { struct MessageView: View {
var message: ChatState.Message var message: ChatComponent.State.Message
var myContactId: Data? var myContactId: Data?
var alignment: Alignment { var alignment: Alignment {
...@@ -199,7 +199,7 @@ public struct ChatView_Previews: PreviewProvider { ...@@ -199,7 +199,7 @@ public struct ChatView_Previews: PreviewProvider {
public static var previews: some View { public static var previews: some View {
NavigationView { NavigationView {
ChatView(store: Store( ChatView(store: Store(
initialState: ChatState( initialState: ChatComponent.State(
id: .contact("contact-id".data(using: .utf8)!), id: .contact("contact-id".data(using: .utf8)!),
myContactId: "my-contact-id".data(using: .utf8)!, myContactId: "my-contact-id".data(using: .utf8)!,
messages: [ messages: [
...@@ -262,8 +262,7 @@ public struct ChatView_Previews: PreviewProvider { ...@@ -262,8 +262,7 @@ public struct ChatView_Previews: PreviewProvider {
failure: "Something went wrong when fetching messages from database.", failure: "Something went wrong when fetching messages from database.",
sendFailure: "Something went wrong when sending message." sendFailure: "Something went wrong when sending message."
), ),
reducer: .empty, reducer: EmptyReducer()
environment: ()
)) ))
} }
} }
......
...@@ -8,15 +8,14 @@ import XXMessengerClient ...@@ -8,15 +8,14 @@ import XXMessengerClient
import XXModels import XXModels
@testable import ChatFeature @testable import ChatFeature
final class ChatFeatureTests: XCTestCase { final class ChatComponentTests: XCTestCase {
func testStart() { func testStart() {
let contactId = "contact-id".data(using: .utf8)! let contactId = "contact-id".data(using: .utf8)!
let myContactId = "my-contact-id".data(using: .utf8)! let myContactId = "my-contact-id".data(using: .utf8)!
let store = TestStore( let store = TestStore(
initialState: ChatState(id: .contact(contactId)), initialState: ChatComponent.State(id: .contact(contactId)),
reducer: chatReducer, reducer: ChatComponent()
environment: .unimplemented
) )
var didFetchMessagesWithQuery: [XXModels.Message.Query] = [] var didFetchMessagesWithQuery: [XXModels.Message.Query] = []
...@@ -24,9 +23,9 @@ final class ChatFeatureTests: XCTestCase { ...@@ -24,9 +23,9 @@ final class ChatFeatureTests: XCTestCase {
var didFetchFileTransfersWithQuery: [XXModels.FileTransfer.Query] = [] var didFetchFileTransfersWithQuery: [XXModels.FileTransfer.Query] = []
let fileTransfersPublisher = PassthroughSubject<[XXModels.FileTransfer], Error>() let fileTransfersPublisher = PassthroughSubject<[XXModels.FileTransfer], Error>()
store.environment.mainQueue = .immediate store.dependencies.app.mainQueue = .immediate
store.environment.bgQueue = .immediate store.dependencies.app.bgQueue = .immediate
store.environment.messenger.e2e.get = { store.dependencies.app.messenger.e2e.get = {
var e2e: E2E = .unimplemented var e2e: E2E = .unimplemented
e2e.getContact.run = { e2e.getContact.run = {
var contact: XXClient.Contact = .unimplemented(Data()) var contact: XXClient.Contact = .unimplemented(Data())
...@@ -35,7 +34,7 @@ final class ChatFeatureTests: XCTestCase { ...@@ -35,7 +34,7 @@ final class ChatFeatureTests: XCTestCase {
} }
return e2e return e2e
} }
store.environment.db.run = { store.dependencies.app.dbManager.getDB.run = {
var db: Database = .unimplemented var db: Database = .unimplemented
db.fetchMessagesPublisher.run = { query in db.fetchMessagesPublisher.run = { query in
didFetchMessagesWithQuery.append(query) didFetchMessagesWithQuery.append(query)
...@@ -113,7 +112,7 @@ final class ChatFeatureTests: XCTestCase { ...@@ -113,7 +112,7 @@ final class ChatFeatureTests: XCTestCase {
sentFileTransfer, sentFileTransfer,
]) ])
let expectedMessages = IdentifiedArrayOf<ChatState.Message>(uniqueElements: [ let expectedMessages = IdentifiedArrayOf<ChatComponent.State.Message>(uniqueElements: [
.init( .init(
id: 1, id: 1,
date: Date(timeIntervalSince1970: 1), date: Date(timeIntervalSince1970: 1),
...@@ -142,17 +141,16 @@ final class ChatFeatureTests: XCTestCase { ...@@ -142,17 +141,16 @@ final class ChatFeatureTests: XCTestCase {
func testStartFailure() { func testStartFailure() {
let store = TestStore( let store = TestStore(
initialState: ChatState(id: .contact("contact-id".data(using: .utf8)!)), initialState: ChatComponent.State(id: .contact("contact-id".data(using: .utf8)!)),
reducer: chatReducer, reducer: ChatComponent()
environment: .unimplemented
) )
struct Failure: Error {} struct Failure: Error {}
let error = Failure() let error = Failure()
store.environment.mainQueue = .immediate store.dependencies.app.mainQueue = .immediate
store.environment.bgQueue = .immediate store.dependencies.app.bgQueue = .immediate
store.environment.messenger.e2e.get = { store.dependencies.app.messenger.e2e.get = {
var e2e: E2E = .unimplemented var e2e: E2E = .unimplemented
e2e.getContact.run = { e2e.getContact.run = {
var contact: XXClient.Contact = .unimplemented(Data()) var contact: XXClient.Contact = .unimplemented(Data())
...@@ -176,14 +174,13 @@ final class ChatFeatureTests: XCTestCase { ...@@ -176,14 +174,13 @@ final class ChatFeatureTests: XCTestCase {
var sendMessageCompletion: SendMessage.Completion? var sendMessageCompletion: SendMessage.Completion?
let store = TestStore( let store = TestStore(
initialState: ChatState(id: .contact("contact-id".data(using: .utf8)!)), initialState: ChatComponent.State(id: .contact("contact-id".data(using: .utf8)!)),
reducer: chatReducer, reducer: ChatComponent()
environment: .unimplemented
) )
store.environment.mainQueue = .immediate store.dependencies.app.mainQueue = .immediate
store.environment.bgQueue = .immediate store.dependencies.app.bgQueue = .immediate
store.environment.sendMessage.run = { text, recipientId, _, completion in store.dependencies.app.sendMessage.run = { text, recipientId, _, completion in
didSendMessageWithParams.append(.init(text: text, recipientId: recipientId)) didSendMessageWithParams.append(.init(text: text, recipientId: recipientId))
sendMessageCompletion = completion sendMessageCompletion = completion
} }
...@@ -208,17 +205,16 @@ final class ChatFeatureTests: XCTestCase { ...@@ -208,17 +205,16 @@ final class ChatFeatureTests: XCTestCase {
var sendMessageCompletion: SendMessage.Completion? var sendMessageCompletion: SendMessage.Completion?
let store = TestStore( let store = TestStore(
initialState: ChatState( initialState: ChatComponent.State(
id: .contact("contact-id".data(using: .utf8)!), id: .contact("contact-id".data(using: .utf8)!),
text: "Hello" text: "Hello"
), ),
reducer: chatReducer, reducer: ChatComponent()
environment: .unimplemented
) )
store.environment.mainQueue = .immediate store.dependencies.app.mainQueue = .immediate
store.environment.bgQueue = .immediate store.dependencies.app.bgQueue = .immediate
store.environment.sendMessage.run = { _, _, onError, completion in store.dependencies.app.sendMessage.run = { _, _, onError, completion in
sendMessageOnError = onError sendMessageOnError = onError
sendMessageCompletion = completion sendMessageCompletion = completion
} }
...@@ -250,14 +246,13 @@ final class ChatFeatureTests: XCTestCase { ...@@ -250,14 +246,13 @@ final class ChatFeatureTests: XCTestCase {
var sendImageCompletion: SendImage.Completion? var sendImageCompletion: SendImage.Completion?
let store = TestStore( let store = TestStore(
initialState: ChatState(id: .contact("contact-id".data(using: .utf8)!)), initialState: ChatComponent.State(id: .contact("contact-id".data(using: .utf8)!)),
reducer: chatReducer, reducer: ChatComponent()
environment: .unimplemented
) )
store.environment.mainQueue = .immediate store.dependencies.app.mainQueue = .immediate
store.environment.bgQueue = .immediate store.dependencies.app.bgQueue = .immediate
store.environment.sendImage.run = { image, recipientId, _, completion in store.dependencies.app.sendImage.run = { image, recipientId, _, completion in
didSendImageWithParams.append(.init(image: image, recipientId: recipientId)) didSendImageWithParams.append(.init(image: image, recipientId: recipientId))
sendImageCompletion = completion sendImageCompletion = completion
} }
...@@ -277,16 +272,15 @@ final class ChatFeatureTests: XCTestCase { ...@@ -277,16 +272,15 @@ final class ChatFeatureTests: XCTestCase {
var sendImageCompletion: SendImage.Completion? var sendImageCompletion: SendImage.Completion?
let store = TestStore( let store = TestStore(
initialState: ChatState( initialState: ChatComponent.State(
id: .contact("contact-id".data(using: .utf8)!) id: .contact("contact-id".data(using: .utf8)!)
), ),
reducer: chatReducer, reducer: ChatComponent()
environment: .unimplemented
) )
store.environment.mainQueue = .immediate store.dependencies.app.mainQueue = .immediate
store.environment.bgQueue = .immediate store.dependencies.app.bgQueue = .immediate
store.environment.sendImage.run = { _, _, onError, completion in store.dependencies.app.sendImage.run = { _, _, onError, completion in
sendImageOnError = onError sendImageOnError = onError
sendImageCompletion = completion sendImageCompletion = completion
} }
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment