diff --git a/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/GroupFeature.xcscheme b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/GroupFeature.xcscheme new file mode 100644 index 0000000000000000000000000000000000000000..a2c612b798714cdc30572dad61ce6b5615e7f374 --- /dev/null +++ b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/GroupFeature.xcscheme @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1410" + version = "1.3"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "GroupFeature" + BuildableName = "GroupFeature" + BlueprintName = "GroupFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> + <Testables> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "GroupFeatureTests" + BuildableName = "GroupFeatureTests" + BlueprintName = "GroupFeatureTests" + ReferencedContainer = "container:"> + </BuildableReference> + </TestableReference> + </Testables> + </TestAction> + <LaunchAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + launchStyle = "0" + useCustomWorkingDirectory = "NO" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + allowLocationSimulation = "YES"> + </LaunchAction> + <ProfileAction + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES"> + <MacroExpansion> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "GroupFeature" + BuildableName = "GroupFeature" + BlueprintName = "GroupFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </MacroExpansion> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index f4fa8cab5742fa457d07a1761ac84022860dfd71..2c22609f63e8b057bf780e2c47305c76f4f6c634 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -22,6 +22,7 @@ let package = Package( .library(name: "ContactFeature", targets: ["ContactFeature"]), .library(name: "ContactLookupFeature", targets: ["ContactLookupFeature"]), .library(name: "ContactsFeature", targets: ["ContactsFeature"]), + .library(name: "GroupFeature", targets: ["GroupFeature"]), .library(name: "GroupsFeature", targets: ["GroupsFeature"]), .library(name: "HomeFeature", targets: ["HomeFeature"]), .library(name: "MyContactFeature", targets: ["MyContactFeature"]), @@ -262,10 +263,31 @@ let package = Package( ], swiftSettings: swiftSettings ), + .target( + name: "GroupFeature", + dependencies: [ + .target(name: "AppCore"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "ComposablePresentation", package: "swift-composable-presentation"), + .product(name: "XXClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "XXModels", package: "client-ios-db"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "GroupFeatureTests", + dependencies: [ + .target(name: "GroupFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), + ], + swiftSettings: swiftSettings + ), .target( name: "GroupsFeature", dependencies: [ .target(name: "AppCore"), + .target(name: "GroupFeature"), .target(name: "NewGroupFeature"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "ComposablePresentation", package: "swift-composable-presentation"), diff --git a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme index 45d9c2cb833a9f0bda12be9ec2b5b7bf1839b9b7..5cd339fe12651b68629b2b533f8add948a6211a6 100644 --- a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme +++ b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme @@ -119,6 +119,16 @@ ReferencedContainer = "container:.."> </BuildableReference> </TestableReference> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "GroupFeatureTests" + BuildableName = "GroupFeatureTests" + BlueprintName = "GroupFeatureTests" + ReferencedContainer = "container:.."> + </BuildableReference> + </TestableReference> <TestableReference skipped = "NO"> <BuildableReference diff --git a/Examples/xx-messenger/Sources/GroupFeature/GroupComponent.swift b/Examples/xx-messenger/Sources/GroupFeature/GroupComponent.swift new file mode 100644 index 0000000000000000000000000000000000000000..d9b7d23306b64b6924d5ce7ff3351728667476a3 --- /dev/null +++ b/Examples/xx-messenger/Sources/GroupFeature/GroupComponent.swift @@ -0,0 +1,94 @@ +import AppCore +import ComposableArchitecture +import Foundation +import XXMessengerClient +import XXModels + +public struct GroupComponent: ReducerProtocol { + public struct State: Equatable { + public init( + groupId: XXModels.Group.ID, + groupInfo: XXModels.GroupInfo? = nil, + isJoining: Bool = false, + joinFailure: String? = nil + ) { + self.groupId = groupId + self.groupInfo = groupInfo + self.isJoining = isJoining + self.joinFailure = joinFailure + } + + public var groupId: XXModels.Group.ID + public var groupInfo: XXModels.GroupInfo? + public var isJoining: Bool + public var joinFailure: String? + } + + public enum Action: Equatable { + case start + case didFetchGroupInfo(XXModels.GroupInfo?) + case joinButtonTapped + case didJoin + case didFailToJoin(String) + } + + public init() {} + + @Dependency(\.app.messenger) var messenger: Messenger + @Dependency(\.app.dbManager.getDB) var db: DBManagerGetDB + @Dependency(\.app.mainQueue) var mainQueue: AnySchedulerOf<DispatchQueue> + @Dependency(\.app.bgQueue) var bgQueue: AnySchedulerOf<DispatchQueue> + + public var body: some ReducerProtocol<State, Action> { + Reduce { state, action in + switch action { + case .start: + return Effect + .catching { try db() } + .flatMap { [state] in + let query = GroupInfo.Query(groupId: state.groupId) + return $0.fetchGroupInfosPublisher(query).map(\.first) + } + .assertNoFailure() + .map(Action.didFetchGroupInfo) + .subscribe(on: bgQueue) + .receive(on: mainQueue) + .eraseToEffect() + + case .didFetchGroupInfo(let groupInfo): + state.groupInfo = groupInfo + return .none + + case .joinButtonTapped: + guard let info = state.groupInfo else { return .none } + state.isJoining = true + state.joinFailure = nil + return Effect.result { + do { + let groupChat = try messenger.groupChat.tryGet() + try groupChat.joinGroup(serializedGroupData: info.group.serialized) + var group = info.group + group.authStatus = .participating + try db().saveGroup(group) + return .success(.didJoin) + } catch { + return .success(.didFailToJoin(error.localizedDescription)) + } + } + .subscribe(on: bgQueue) + .receive(on: mainQueue) + .eraseToEffect() + + case .didJoin: + state.isJoining = false + state.joinFailure = nil + return .none + + case .didFailToJoin(let failure): + state.isJoining = false + state.joinFailure = failure + return .none + } + } + } +} diff --git a/Examples/xx-messenger/Sources/GroupFeature/GroupView.swift b/Examples/xx-messenger/Sources/GroupFeature/GroupView.swift new file mode 100644 index 0000000000000000000000000000000000000000..67b880f4b1ad3f8e790f51f1202624f195d0d60a --- /dev/null +++ b/Examples/xx-messenger/Sources/GroupFeature/GroupView.swift @@ -0,0 +1,115 @@ +import AppCore +import ComposableArchitecture +import SwiftUI +import XXModels + +public struct GroupView: View { + public typealias Component = GroupComponent + typealias ViewStore = ComposableArchitecture.ViewStore<ViewState, Component.Action> + + public init(store: StoreOf<Component>) { + self.store = store + } + + let store: StoreOf<Component> + + struct ViewState: Equatable { + init(state: Component.State) { + info = state.groupInfo + isJoining = state.isJoining + joinFailure = state.joinFailure + } + + var info: XXModels.GroupInfo? + var isJoining: Bool + var joinFailure: String? + } + + public var body: some View { + WithViewStore(store, observe: ViewState.init) { viewStore in + Form { + if let info = viewStore.info { + Section("Name") { + Text(info.group.name) + } + + Section("Leader") { + Label(info.leader.username ?? "", systemImage: "person.badge.shield.checkmark") + } + + Section("Members") { + ForEach(info.members.filter { $0 != info.leader }) { contact in + Label(contact.username ?? "", systemImage: "person") + } + } + + Section("Status") { + GroupAuthStatusView(info.group.authStatus) + + if case .pending = info.group.authStatus { + Button { + viewStore.send(.joinButtonTapped) + } label: { + HStack { + Text("Join") + Spacer() + if viewStore.isJoining { + ProgressView() + } else { + Image(systemName: "play.fill") + } + } + } + .disabled(viewStore.isJoining) + } + + if let failure = viewStore.joinFailure { + Text(failure) + } + } + } + } + .navigationTitle("Group") + .task { viewStore.send(.start) } + } + } +} + +#if DEBUG +public struct GroupView_Previews: PreviewProvider { + public static var previews: some View { + NavigationView { + GroupView(store: Store( + initialState: GroupComponent.State( + groupId: "group-id".data(using: .utf8)!, + groupInfo: .init( + group: .init( + id: "group-id".data(using: .utf8)!, + name: "Preview group", + leaderId: "group-leader-id".data(using: .utf8)!, + createdAt: Date(timeIntervalSince1970: TimeInterval(86_400)), + authStatus: .participating, + serialized: "group-serialized".data(using: .utf8)! + ), + leader: .init( + id: "group-leader-id".data(using: .utf8)!, + username: "Group leader" + ), + members: [ + .init( + id: "member-1-id".data(using: .utf8)!, + username: "Member 1" + ), + .init( + id: "member-2-id".data(using: .utf8)!, + username: "Member 2" + ), + ] + ) + ), + reducer: EmptyReducer() + )) + } + } +} +#endif diff --git a/Examples/xx-messenger/Sources/GroupsFeature/GroupsComponent.swift b/Examples/xx-messenger/Sources/GroupsFeature/GroupsComponent.swift index a5f3077db275e5a9cf51d6a9ac34f670d9a0e0f4..91e6dc55a8aeacfe8f811fffeabe092c391b6fd0 100644 --- a/Examples/xx-messenger/Sources/GroupsFeature/GroupsComponent.swift +++ b/Examples/xx-messenger/Sources/GroupsFeature/GroupsComponent.swift @@ -2,6 +2,7 @@ import AppCore import ComposableArchitecture import ComposablePresentation import Foundation +import GroupFeature import NewGroupFeature import XXModels @@ -9,23 +10,28 @@ public struct GroupsComponent: ReducerProtocol { public struct State: Equatable { public init( groups: IdentifiedArrayOf<Group> = [], - newGroup: NewGroupComponent.State? = nil + newGroup: NewGroupComponent.State? = nil, + group: GroupComponent.State? = nil ) { self.groups = groups self.newGroup = newGroup + self.group = group } public var groups: IdentifiedArrayOf<XXModels.Group> = [] public var newGroup: NewGroupComponent.State? + public var group: GroupComponent.State? } public enum Action: Equatable { case start case didFetchGroups([XXModels.Group]) case didSelectGroup(XXModels.Group) + case didDismissGroup case newGroupButtonTapped case newGroupDismissed case newGroup(NewGroupComponent.Action) + case group(GroupComponent.Action) } public init() {} @@ -51,7 +57,12 @@ public struct GroupsComponent: ReducerProtocol { state.groups = IdentifiedArray(uniqueElements: groups) return .none - case .didSelectGroup(_): + case .didSelectGroup(let group): + state.group = GroupComponent.State(groupId: group.id) + return .none + + case .didDismissGroup: + state.group = nil return .none case .newGroupButtonTapped: @@ -66,7 +77,7 @@ public struct GroupsComponent: ReducerProtocol { state.newGroup = nil return .none - case .newGroup(_): + case .newGroup(_), .group(_): return .none } } @@ -76,5 +87,11 @@ public struct GroupsComponent: ReducerProtocol { action: /Action.newGroup, presented: { NewGroupComponent() } ) + .presenting( + state: .keyPath(\.group), + id: .notNil(), + action: /Action.group, + presented: { GroupComponent() } + ) } } diff --git a/Examples/xx-messenger/Sources/GroupsFeature/GroupsView.swift b/Examples/xx-messenger/Sources/GroupsFeature/GroupsView.swift index 44c38ce99d8b1f418b896970c789f8aa8ac5e113..1391f6d91d2b23b62c0da579c410cd9213e6af54 100644 --- a/Examples/xx-messenger/Sources/GroupsFeature/GroupsView.swift +++ b/Examples/xx-messenger/Sources/GroupsFeature/GroupsView.swift @@ -1,6 +1,7 @@ import AppCore import ComposableArchitecture import ComposablePresentation +import GroupFeature import NewGroupFeature import SwiftUI import XXModels @@ -41,6 +42,14 @@ public struct GroupsView: View { onDeactivate: { viewStore.send(.newGroupDismissed) }, destination: NewGroupView.init )) + .background(NavigationLinkWithStore( + store.scope( + state: \.group, + action: Component.Action.group + ), + onDeactivate: { viewStore.send(.didDismissGroup) }, + destination: GroupView.init + )) .task { viewStore.send(.start) } } } diff --git a/Examples/xx-messenger/Tests/GroupFeatureTests/GroupComponentTests.swift b/Examples/xx-messenger/Tests/GroupFeatureTests/GroupComponentTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..3c7193fc4ebea418f97800fae37e782d982d497f --- /dev/null +++ b/Examples/xx-messenger/Tests/GroupFeatureTests/GroupComponentTests.swift @@ -0,0 +1,172 @@ +import Combine +import ComposableArchitecture +import CustomDump +import XCTest +import XXClient +import XXMessengerClient +import XXModels +@testable import GroupFeature + +final class GroupComponentTests: XCTestCase { + enum Action: Equatable { + case didFetchGroupInfos(GroupInfo.Query) + case didJoinGroup(Data) + case didSaveGroup(XXModels.Group) + } + + var actions: [Action]! + + override func setUp() { + actions = [] + } + + override func tearDown() { + actions = nil + } + + func testStart() { + let groupId = "group-id".data(using: .utf8)! + let groupInfosSubject = PassthroughSubject<[GroupInfo], Error>() + + let store = TestStore( + initialState: GroupComponent.State( + groupId: groupId + ), + reducer: GroupComponent() + ) + + store.dependencies.app.mainQueue = .immediate + store.dependencies.app.bgQueue = .immediate + store.dependencies.app.dbManager.getDB.run = { + var db: Database = .unimplemented + db.fetchGroupInfosPublisher.run = { query in + self.actions.append(.didFetchGroupInfos(query)) + return groupInfosSubject.eraseToAnyPublisher() + } + return db + } + + store.send(.start) + + XCTAssertNoDifference(actions, [ + .didFetchGroupInfos(.init(groupId: groupId)), + ]) + + let groupInfo = GroupInfo.stub() + groupInfosSubject.send([groupInfo]) + + store.receive(.didFetchGroupInfo(groupInfo)) { + $0.groupInfo = groupInfo + } + + groupInfosSubject.send(completion: .finished) + } + + func testJoinGroup() { + var groupInfo = GroupInfo.stub() + groupInfo.group.authStatus = .pending + + let store = TestStore( + initialState: GroupComponent.State( + groupId: groupInfo.group.id, + groupInfo: groupInfo + ), + reducer: GroupComponent() + ) + + store.dependencies.app.mainQueue = .immediate + store.dependencies.app.bgQueue = .immediate + store.dependencies.app.messenger.groupChat.get = { + var groupChat: GroupChat = .unimplemented + groupChat.joinGroup.run = { serializedGroupData in + self.actions.append(.didJoinGroup(serializedGroupData)) + } + return groupChat + } + store.dependencies.app.dbManager.getDB.run = { + var db: Database = .unimplemented + db.saveGroup.run = { group in + self.actions.append(.didSaveGroup(group)) + return group + } + return db + } + + store.send(.joinButtonTapped) { + $0.isJoining = true + } + + XCTAssertNoDifference(actions, [ + .didJoinGroup(groupInfo.group.serialized), + .didSaveGroup({ + var group = groupInfo.group + group.authStatus = .participating + return group + }()) + ]) + + store.receive(.didJoin) { + $0.isJoining = false + } + } + + func testJoinGroupFailure() { + let groupInfo = GroupInfo.stub() + struct Failure: Error {} + let failure = Failure() + + let store = TestStore( + initialState: GroupComponent.State( + groupId: groupInfo.group.id, + groupInfo: groupInfo + ), + reducer: GroupComponent() + ) + + store.dependencies.app.mainQueue = .immediate + store.dependencies.app.bgQueue = .immediate + store.dependencies.app.messenger.groupChat.get = { + var groupChat: GroupChat = .unimplemented + groupChat.joinGroup.run = { _ in throw failure } + return groupChat + } + + store.send(.joinButtonTapped) { + $0.isJoining = true + } + + store.receive(.didFailToJoin(failure.localizedDescription)) { + $0.isJoining = false + $0.joinFailure = failure.localizedDescription + } + } +} + +private extension XXModels.GroupInfo { + static func stub() -> XXModels.GroupInfo { + XXModels.GroupInfo( + group: .init( + id: "group-id".data(using: .utf8)!, + name: "Group Name", + leaderId: "group-leader-id".data(using: .utf8)!, + createdAt: Date(timeIntervalSince1970: TimeInterval(86_400)), + authStatus: .participating, + serialized: "group-serialized".data(using: .utf8)! + ), + leader: .init( + id: "group-leader-id".data(using: .utf8)!, + username: "Group leader" + ), + members: [ + .init( + id: "member-1-id".data(using: .utf8)!, + username: "Member 1" + ), + .init( + id: "member-2-id".data(using: .utf8)!, + username: "Member 2" + ), + ] + ) + } +} diff --git a/Examples/xx-messenger/Tests/GroupsFeatureTests/GroupsComponentTests.swift b/Examples/xx-messenger/Tests/GroupsFeatureTests/GroupsComponentTests.swift index f8b06a183149a674542e9d600433084e496002d2..5742aaf73119e9b3687191f587de4b2203f4f944 100644 --- a/Examples/xx-messenger/Tests/GroupsFeatureTests/GroupsComponentTests.swift +++ b/Examples/xx-messenger/Tests/GroupsFeatureTests/GroupsComponentTests.swift @@ -1,6 +1,7 @@ import Combine import ComposableArchitecture import CustomDump +import GroupFeature import NewGroupFeature import XCTest import XXModels @@ -61,18 +62,44 @@ final class GroupsComponentTests: XCTestCase { } func testSelectGroup() { + let groups: [XXModels.Group] = [ + .stub(1), + .stub(2), + .stub(3), + ] + + let store = TestStore( + initialState: GroupsComponent.State( + groups: IdentifiedArray(uniqueElements: groups) + ), + reducer: GroupsComponent() + ) + + store.send(.didSelectGroup(groups[1])) { + $0.group = GroupComponent.State(groupId: groups[1].id) + } + } + + func testDismissGroup() { + let groups: [XXModels.Group] = [ + .stub(1), + .stub(2), + .stub(3), + ] + let store = TestStore( initialState: GroupsComponent.State( - groups: IdentifiedArray(uniqueElements: [ - .stub(1), - .stub(2), - .stub(3), - ]) + groups: IdentifiedArray(uniqueElements: groups), + group: GroupComponent.State( + groupId: groups[1].id + ) ), reducer: GroupsComponent() ) - store.send(.didSelectGroup(.stub(2))) + store.send(.didDismissGroup) { + $0.group = nil + } } func testPresentNewGroup() {