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

Merge branch 'feature/messenger-example-join-group' into 'development'

[Messenger example] join group

See merge request elixxir/elixxir-dapps-sdk-swift!151
parents 7dc9fef0 a5ab6975
No related branches found
No related tags found
2 merge requests!153Release 1.1.0,!151[Messenger example] join group
Showing with 553 additions and 9 deletions
<?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>
...@@ -22,6 +22,7 @@ let package = Package( ...@@ -22,6 +22,7 @@ let package = Package(
.library(name: "ContactFeature", targets: ["ContactFeature"]), .library(name: "ContactFeature", targets: ["ContactFeature"]),
.library(name: "ContactLookupFeature", targets: ["ContactLookupFeature"]), .library(name: "ContactLookupFeature", targets: ["ContactLookupFeature"]),
.library(name: "ContactsFeature", targets: ["ContactsFeature"]), .library(name: "ContactsFeature", targets: ["ContactsFeature"]),
.library(name: "GroupFeature", targets: ["GroupFeature"]),
.library(name: "GroupsFeature", targets: ["GroupsFeature"]), .library(name: "GroupsFeature", targets: ["GroupsFeature"]),
.library(name: "HomeFeature", targets: ["HomeFeature"]), .library(name: "HomeFeature", targets: ["HomeFeature"]),
.library(name: "MyContactFeature", targets: ["MyContactFeature"]), .library(name: "MyContactFeature", targets: ["MyContactFeature"]),
...@@ -262,10 +263,31 @@ let package = Package( ...@@ -262,10 +263,31 @@ let package = Package(
], ],
swiftSettings: swiftSettings 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( .target(
name: "GroupsFeature", name: "GroupsFeature",
dependencies: [ dependencies: [
.target(name: "AppCore"), .target(name: "AppCore"),
.target(name: "GroupFeature"),
.target(name: "NewGroupFeature"), .target(name: "NewGroupFeature"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "ComposablePresentation", package: "swift-composable-presentation"), .product(name: "ComposablePresentation", package: "swift-composable-presentation"),
......
...@@ -119,6 +119,16 @@ ...@@ -119,6 +119,16 @@
ReferencedContainer = "container:.."> ReferencedContainer = "container:..">
</BuildableReference> </BuildableReference>
</TestableReference> </TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "GroupFeatureTests"
BuildableName = "GroupFeatureTests"
BlueprintName = "GroupFeatureTests"
ReferencedContainer = "container:..">
</BuildableReference>
</TestableReference>
<TestableReference <TestableReference
skipped = "NO"> skipped = "NO">
<BuildableReference <BuildableReference
......
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
}
}
}
}
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
...@@ -2,6 +2,7 @@ import AppCore ...@@ -2,6 +2,7 @@ import AppCore
import ComposableArchitecture import ComposableArchitecture
import ComposablePresentation import ComposablePresentation
import Foundation import Foundation
import GroupFeature
import NewGroupFeature import NewGroupFeature
import XXModels import XXModels
...@@ -9,23 +10,28 @@ public struct GroupsComponent: ReducerProtocol { ...@@ -9,23 +10,28 @@ public struct GroupsComponent: ReducerProtocol {
public struct State: Equatable { public struct State: Equatable {
public init( public init(
groups: IdentifiedArrayOf<Group> = [], groups: IdentifiedArrayOf<Group> = [],
newGroup: NewGroupComponent.State? = nil newGroup: NewGroupComponent.State? = nil,
group: GroupComponent.State? = nil
) { ) {
self.groups = groups self.groups = groups
self.newGroup = newGroup self.newGroup = newGroup
self.group = group
} }
public var groups: IdentifiedArrayOf<XXModels.Group> = [] public var groups: IdentifiedArrayOf<XXModels.Group> = []
public var newGroup: NewGroupComponent.State? public var newGroup: NewGroupComponent.State?
public var group: GroupComponent.State?
} }
public enum Action: Equatable { public enum Action: Equatable {
case start case start
case didFetchGroups([XXModels.Group]) case didFetchGroups([XXModels.Group])
case didSelectGroup(XXModels.Group) case didSelectGroup(XXModels.Group)
case didDismissGroup
case newGroupButtonTapped case newGroupButtonTapped
case newGroupDismissed case newGroupDismissed
case newGroup(NewGroupComponent.Action) case newGroup(NewGroupComponent.Action)
case group(GroupComponent.Action)
} }
public init() {} public init() {}
...@@ -51,7 +57,12 @@ public struct GroupsComponent: ReducerProtocol { ...@@ -51,7 +57,12 @@ public struct GroupsComponent: ReducerProtocol {
state.groups = IdentifiedArray(uniqueElements: groups) state.groups = IdentifiedArray(uniqueElements: groups)
return .none return .none
case .didSelectGroup(_): case .didSelectGroup(let group):
state.group = GroupComponent.State(groupId: group.id)
return .none
case .didDismissGroup:
state.group = nil
return .none return .none
case .newGroupButtonTapped: case .newGroupButtonTapped:
...@@ -66,7 +77,7 @@ public struct GroupsComponent: ReducerProtocol { ...@@ -66,7 +77,7 @@ public struct GroupsComponent: ReducerProtocol {
state.newGroup = nil state.newGroup = nil
return .none return .none
case .newGroup(_): case .newGroup(_), .group(_):
return .none return .none
} }
} }
...@@ -76,5 +87,11 @@ public struct GroupsComponent: ReducerProtocol { ...@@ -76,5 +87,11 @@ public struct GroupsComponent: ReducerProtocol {
action: /Action.newGroup, action: /Action.newGroup,
presented: { NewGroupComponent() } presented: { NewGroupComponent() }
) )
.presenting(
state: .keyPath(\.group),
id: .notNil(),
action: /Action.group,
presented: { GroupComponent() }
)
} }
} }
import AppCore import AppCore
import ComposableArchitecture import ComposableArchitecture
import ComposablePresentation import ComposablePresentation
import GroupFeature
import NewGroupFeature import NewGroupFeature
import SwiftUI import SwiftUI
import XXModels import XXModels
...@@ -41,6 +42,14 @@ public struct GroupsView: View { ...@@ -41,6 +42,14 @@ public struct GroupsView: View {
onDeactivate: { viewStore.send(.newGroupDismissed) }, onDeactivate: { viewStore.send(.newGroupDismissed) },
destination: NewGroupView.init destination: NewGroupView.init
)) ))
.background(NavigationLinkWithStore(
store.scope(
state: \.group,
action: Component.Action.group
),
onDeactivate: { viewStore.send(.didDismissGroup) },
destination: GroupView.init
))
.task { viewStore.send(.start) } .task { viewStore.send(.start) }
} }
} }
......
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"
),
]
)
}
}
import Combine import Combine
import ComposableArchitecture import ComposableArchitecture
import CustomDump import CustomDump
import GroupFeature
import NewGroupFeature import NewGroupFeature
import XCTest import XCTest
import XXModels import XXModels
...@@ -61,18 +62,44 @@ final class GroupsComponentTests: XCTestCase { ...@@ -61,18 +62,44 @@ final class GroupsComponentTests: XCTestCase {
} }
func testSelectGroup() { func testSelectGroup() {
let groups: [XXModels.Group] = [
.stub(1),
.stub(2),
.stub(3),
]
let store = TestStore( let store = TestStore(
initialState: GroupsComponent.State( initialState: GroupsComponent.State(
groups: IdentifiedArray(uniqueElements: [ 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(1),
.stub(2), .stub(2),
.stub(3), .stub(3),
]) ]
let store = TestStore(
initialState: GroupsComponent.State(
groups: IdentifiedArray(uniqueElements: groups),
group: GroupComponent.State(
groupId: groups[1].id
)
), ),
reducer: GroupsComponent() reducer: GroupsComponent()
) )
store.send(.didSelectGroup(.stub(2))) store.send(.didDismissGroup) {
$0.group = nil
}
} }
func testPresentNewGroup() { func testPresentNewGroup() {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment