mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-04-25 19:10:49 +02:00
Merge commit 'aaadcc73674cc8886e363693a7d7c08ac9b4f516' into feature/4260_merge_foss_1_10_2
# Conflicts: # Config/AppVersion.xcconfig # Podfile # Podfile.lock # Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved # Riot/Managers/EncryptionKeyManager/EncryptionKeyManager.swift # Riot/Modules/Application/LegacyAppDelegate.m # Riot/Modules/Authentication/AuthenticationCoordinator.swift # Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift # Riot/Modules/ContextMenu/ActionProviders/RoomActionProvider.swift # Riot/Modules/Home/AllChats/AllChatsViewController.swift # Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift # Riot/Modules/Room/RoomInfo/RoomInfoList/RoomInfoListViewController.swift # Riot/Modules/Room/Settings/RoomSettingsViewController.m # fastlane/Fastfile
This commit is contained in:
@@ -1,63 +0,0 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias AllChatsOnboardingViewModelType = StateStoreViewModel<AllChatsOnboardingViewState, AllChatsOnboardingViewAction>
|
||||
|
||||
class AllChatsOnboardingViewModel: AllChatsOnboardingViewModelType, AllChatsOnboardingViewModelProtocol {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var completion: ((AllChatsOnboardingViewModelResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
static func makeAllChatsOnboardingViewModel() -> AllChatsOnboardingViewModelProtocol {
|
||||
AllChatsOnboardingViewModel()
|
||||
}
|
||||
|
||||
private init() {
|
||||
super.init(initialViewState: Self.defaultState())
|
||||
}
|
||||
|
||||
private static func defaultState() -> AllChatsOnboardingViewState {
|
||||
AllChatsOnboardingViewState(pages: [
|
||||
AllChatsOnboardingPageData(image: Asset.Images.allChatsOnboarding1.image,
|
||||
title: VectorL10n.allChatsOnboardingPageTitle1,
|
||||
message: VectorL10n.allChatsOnboardingPageMessage1),
|
||||
AllChatsOnboardingPageData(image: Asset.Images.allChatsOnboarding2.image,
|
||||
title: VectorL10n.allChatsOnboardingPageTitle2,
|
||||
message: VectorL10n.allChatsOnboardingPageMessage2),
|
||||
AllChatsOnboardingPageData(image: Asset.Images.allChatsOnboarding3.image,
|
||||
title: VectorL10n.allChatsOnboardingPageTitle3,
|
||||
message: VectorL10n.allChatsOnboardingPageMessage3)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: AllChatsOnboardingViewAction) {
|
||||
switch viewAction {
|
||||
case .cancel:
|
||||
completion?(.cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import CommonKit
|
||||
import SwiftUI
|
||||
|
||||
/// All Chats onboarding screen
|
||||
final class AllChatsOnboardingCoordinator: NSObject, Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let hostingController: UIViewController
|
||||
private var viewModel: AllChatsOnboardingViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var completion: (() -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override init() {
|
||||
let viewModel = AllChatsOnboardingViewModel.makeAllChatsOnboardingViewModel()
|
||||
let view = AllChatsOnboarding(viewModel: viewModel.context)
|
||||
self.viewModel = viewModel
|
||||
hostingController = VectorHostingController(rootView: view)
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: hostingController)
|
||||
|
||||
super.init()
|
||||
|
||||
hostingController.presentationController?.delegate = self
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("[AllChatsOnboardingCoordinator] did start.")
|
||||
viewModel.completion = { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
MXLog.debug("[AllChatsOnboardingCoordinator] AllChatsOnboardingViewModel did complete with result: \(result).")
|
||||
switch result {
|
||||
case .cancel:
|
||||
self.completion?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
hostingController
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
/// - Parameters:
|
||||
/// - label: The label to show on the indicator.
|
||||
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
|
||||
private func startLoading(label: String = VectorL10n.loading, isInteractionBlocking: Bool = true) {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loadingIndicator = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIAdaptivePresentationControllerDelegate
|
||||
|
||||
extension AllChatsOnboardingCoordinator: UIAdaptivePresentationControllerDelegate {
|
||||
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@objc protocol AllChatsOnboardingCoordinatorBridgePresenterDelegate {
|
||||
func allChatsOnboardingCoordinatorBridgePresenterDidCancel(_ coordinatorBridgePresenter: AllChatsOnboardingCoordinatorBridgePresenter)
|
||||
}
|
||||
|
||||
/// `AllChatsOnboardingCoordinatorBridgePresenter` enables to start `AllChatsOnboardingCoordinator` from a view controller.
|
||||
/// This bridge is used while waiting for global usage of coordinator pattern.
|
||||
/// It breaks the Coordinator abstraction and it has been introduced for Objective-C compatibility (mainly for integration in legacy view controllers).
|
||||
/// Each bridge should be removed once the underlying Coordinator has been integrated by another Coordinator.
|
||||
@objcMembers
|
||||
final class AllChatsOnboardingCoordinatorBridgePresenter: NSObject {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private var coordinator: AllChatsOnboardingCoordinator?
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var completion: (() -> Void)?
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func present(from viewController: UIViewController, animated: Bool) {
|
||||
let coordinator = AllChatsOnboardingCoordinator()
|
||||
coordinator.completion = { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.completion?()
|
||||
}
|
||||
let presentable = coordinator.toPresentable()
|
||||
viewController.present(presentable, animated: animated, completion: nil)
|
||||
coordinator.start()
|
||||
|
||||
self.coordinator = coordinator
|
||||
}
|
||||
|
||||
func dismiss(animated: Bool, completion: (() -> Void)?) {
|
||||
guard let coordinator = coordinator else {
|
||||
return
|
||||
}
|
||||
coordinator.toPresentable().dismiss(animated: animated) {
|
||||
self.coordinator = nil
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AllChatsOnboarding: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
@State private var selectedTab = 0
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var viewModel: AllChatsOnboardingViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text(VectorL10n.allChatsOnboardingTitle)
|
||||
.font(theme.fonts.title3SB)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.padding()
|
||||
TabView(selection: $selectedTab) {
|
||||
ForEach(viewModel.viewState.pages.indices, id: \.self) { index in
|
||||
let page = viewModel.viewState.pages[index]
|
||||
AllChatsOnboardingPage(image: page.image,
|
||||
title: page.title,
|
||||
message: page.message)
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
|
||||
.indexViewStyle(.page(backgroundDisplayMode: .always))
|
||||
|
||||
Button { onCallToAction() } label: {
|
||||
Text(selectedTab == viewModel.viewState.pages.count - 1 ? VectorL10n.allChatsOnboardingTryIt : VectorL10n.next)
|
||||
.animation(nil)
|
||||
}
|
||||
.buttonStyle(PrimaryActionButtonStyle())
|
||||
.padding()
|
||||
}
|
||||
.background(theme.colors.background.ignoresSafeArea())
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func onCallToAction() {
|
||||
if selectedTab == viewModel.viewState.pages.count - 1 {
|
||||
viewModel.send(viewAction: .cancel)
|
||||
} else {
|
||||
withAnimation {
|
||||
selectedTab += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct AllChatsOnboarding_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AllChatsOnboarding(viewModel: AllChatsOnboardingViewModel.makeAllChatsOnboardingViewModel().context).theme(.light).preferredColorScheme(.light)
|
||||
AllChatsOnboarding(viewModel: AllChatsOnboardingViewModel.makeAllChatsOnboardingViewModel().context).theme(.dark).preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AllChatsOnboardingPage: View {
|
||||
// MARK: - Properties
|
||||
|
||||
let image: UIImage
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
Image(uiImage: image)
|
||||
Spacer()
|
||||
Text(title)
|
||||
.font(theme.fonts.title2B)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.padding(.bottom, 16)
|
||||
Text(message)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(theme.fonts.callout)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct AllChatsOnboardingPage_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
preview.theme(.light).preferredColorScheme(.light)
|
||||
preview.theme(.dark).preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
private static var preview: some View {
|
||||
AllChatsOnboardingPage(image: Asset.Images.allChatsOnboarding1.image,
|
||||
title: VectorL10n.allChatsOnboardingPageTitle1,
|
||||
message: VectorL10n.allChatsOnboardingPageMessage1)
|
||||
}
|
||||
}
|
||||
@@ -59,20 +59,31 @@ extension ComposerLinkActionViewState {
|
||||
}
|
||||
|
||||
var isSaveButtonDisabled: Bool {
|
||||
guard isValidLink else { return true }
|
||||
guard !bindings.linkUrl.isEmpty else { return true }
|
||||
switch linkAction {
|
||||
case .createWithText: return bindings.text.isEmpty
|
||||
default: return false
|
||||
case .create: return false
|
||||
case .edit: return !bindings.hasEditedUrl
|
||||
}
|
||||
}
|
||||
|
||||
private var isValidLink: Bool {
|
||||
guard let url = URL(string: bindings.linkUrl) else { return false }
|
||||
return UIApplication.shared.canOpenURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposerLinkActionBindings {
|
||||
var text: String
|
||||
var linkUrl: String
|
||||
|
||||
private let initialLinkUrl: String
|
||||
fileprivate var hasEditedUrl = false
|
||||
var linkUrl: String {
|
||||
didSet {
|
||||
if !hasEditedUrl && linkUrl != initialLinkUrl {
|
||||
hasEditedUrl = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(text: String, linkUrl: String) {
|
||||
self.text = text
|
||||
self.linkUrl = linkUrl
|
||||
self.initialLinkUrl = linkUrl
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -29,9 +29,7 @@ final class ComposerLinkActionUITests: MockScreenTestCase {
|
||||
let linkTextField = app.textFields["linkTextField"]
|
||||
XCTAssertTrue(linkTextField.exists)
|
||||
linkTextField.tap()
|
||||
linkTextField.typeText("invalid url")
|
||||
XCTAssertFalse(saveButton.isEnabled)
|
||||
linkTextField.clearAndTypeText("https://element.io")
|
||||
linkTextField.clearAndTypeText("element.io")
|
||||
XCTAssertTrue(saveButton.isEnabled)
|
||||
}
|
||||
|
||||
@@ -47,7 +45,7 @@ final class ComposerLinkActionUITests: MockScreenTestCase {
|
||||
let linkTextField = app.textFields["linkTextField"]
|
||||
XCTAssertTrue(linkTextField.exists)
|
||||
linkTextField.tap()
|
||||
linkTextField.typeText("https://element.io")
|
||||
linkTextField.typeText("element.io")
|
||||
XCTAssertFalse(saveButton.isEnabled)
|
||||
textTextField.tap()
|
||||
textTextField.typeText("test")
|
||||
@@ -60,13 +58,15 @@ final class ComposerLinkActionUITests: MockScreenTestCase {
|
||||
XCTAssertTrue(app.buttons[VectorL10n.cancel].exists)
|
||||
let saveButton = app.buttons[VectorL10n.save]
|
||||
XCTAssertTrue(saveButton.exists)
|
||||
XCTAssertTrue(saveButton.isEnabled)
|
||||
XCTAssertFalse(saveButton.isEnabled)
|
||||
XCTAssertFalse(app.textFields["textTextField"].exists)
|
||||
let linkTextField = app.textFields["linkTextField"]
|
||||
XCTAssertTrue(linkTextField.exists)
|
||||
let value = linkTextField.value as? String
|
||||
XCTAssertEqual(value, "https://element.io")
|
||||
linkTextField.clearAndTypeText("invalid url")
|
||||
linkTextField.clearAndTypeText("")
|
||||
XCTAssertFalse(saveButton.isEnabled)
|
||||
linkTextField.clearAndTypeText("matrix.org")
|
||||
XCTAssertTrue(saveButton.isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-17
@@ -53,29 +53,20 @@ final class ComposerLinkActionViewModelTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testEditDefaultState() {
|
||||
let link = "https://element.io"
|
||||
let link = "element.io"
|
||||
setUp(with: .edit(link: link))
|
||||
XCTAssertEqual(context.viewState.bindings.text, "")
|
||||
XCTAssertEqual(context.viewState.bindings.linkUrl, link)
|
||||
XCTAssertFalse(context.viewState.isSaveButtonDisabled)
|
||||
XCTAssertTrue(context.viewState.isSaveButtonDisabled)
|
||||
XCTAssertTrue(context.viewState.shouldDisplayRemoveButton)
|
||||
XCTAssertFalse(context.viewState.shouldDisplayTextField)
|
||||
XCTAssertEqual(context.viewState.title, VectorL10n.wysiwygComposerLinkActionEditTitle)
|
||||
}
|
||||
|
||||
func testUrlValidityCheck() {
|
||||
setUp(with: .create)
|
||||
XCTAssertTrue(context.viewState.isSaveButtonDisabled)
|
||||
context.linkUrl = "invalid url"
|
||||
XCTAssertTrue(context.viewState.isSaveButtonDisabled)
|
||||
context.linkUrl = "https://element.io"
|
||||
XCTAssertFalse(context.viewState.isSaveButtonDisabled)
|
||||
}
|
||||
|
||||
func testTextNotEmptyCheck() {
|
||||
setUp(with: .createWithText)
|
||||
XCTAssertTrue(context.viewState.isSaveButtonDisabled)
|
||||
context.linkUrl = "https://element.io"
|
||||
context.linkUrl = "element.io"
|
||||
XCTAssertTrue(context.viewState.isSaveButtonDisabled)
|
||||
context.text = "text"
|
||||
XCTAssertFalse(context.viewState.isSaveButtonDisabled)
|
||||
@@ -92,7 +83,7 @@ final class ComposerLinkActionViewModelTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testRemoveAction() {
|
||||
setUp(with: .edit(link: "https://element.io"))
|
||||
setUp(with: .edit(link: "element.io"))
|
||||
var result: ComposerLinkActionViewModelResult!
|
||||
viewModel.callback = { value in
|
||||
result = value
|
||||
@@ -107,7 +98,7 @@ final class ComposerLinkActionViewModelTests: XCTestCase {
|
||||
viewModel.callback = { value in
|
||||
result = value
|
||||
}
|
||||
let link = "https://element.io"
|
||||
let link = "element.io"
|
||||
context.linkUrl = link
|
||||
context.send(viewAction: .save)
|
||||
XCTAssertEqual(result, .performOperation(.setLink(urlString: link)))
|
||||
@@ -119,7 +110,7 @@ final class ComposerLinkActionViewModelTests: XCTestCase {
|
||||
viewModel.callback = { value in
|
||||
result = value
|
||||
}
|
||||
let link = "https://element.io"
|
||||
let link = "element.io"
|
||||
context.linkUrl = link
|
||||
let text = "test"
|
||||
context.text = text
|
||||
@@ -128,13 +119,15 @@ final class ComposerLinkActionViewModelTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testSaveActionForEdit() {
|
||||
setUp(with: .edit(link: "https://element.io"))
|
||||
setUp(with: .edit(link: "element.io"))
|
||||
var result: ComposerLinkActionViewModelResult!
|
||||
viewModel.callback = { value in
|
||||
result = value
|
||||
}
|
||||
let link = "https://matrix.org"
|
||||
XCTAssertTrue(context.viewState.isSaveButtonDisabled)
|
||||
let link = "matrix.org"
|
||||
context.linkUrl = link
|
||||
XCTAssertFalse(context.viewState.isSaveButtonDisabled)
|
||||
context.send(viewAction: .save)
|
||||
XCTAssertEqual(result, .performOperation(.setLink(urlString: link)))
|
||||
}
|
||||
|
||||
@@ -34,7 +34,13 @@ enum FormatType {
|
||||
case italic
|
||||
case underline
|
||||
case strikethrough
|
||||
case unorderedList
|
||||
case orderedList
|
||||
case indent
|
||||
case unIndent
|
||||
case inlineCode
|
||||
case codeBlock
|
||||
case quote
|
||||
case link
|
||||
}
|
||||
|
||||
@@ -54,14 +60,26 @@ extension FormatItem {
|
||||
return Asset.Images.bold.name
|
||||
case .italic:
|
||||
return Asset.Images.italic.name
|
||||
case .strikethrough:
|
||||
return Asset.Images.strikethrough.name
|
||||
case .underline:
|
||||
return Asset.Images.underlined.name
|
||||
case .link:
|
||||
return Asset.Images.link.name
|
||||
case .strikethrough:
|
||||
return Asset.Images.strikethrough.name
|
||||
case .unorderedList:
|
||||
return Asset.Images.bulletList.name
|
||||
case .orderedList:
|
||||
return Asset.Images.numberedList.name
|
||||
case .indent:
|
||||
return Asset.Images.indentIncrease.name
|
||||
case .unIndent:
|
||||
return Asset.Images.indentDecrease.name
|
||||
case .inlineCode:
|
||||
return Asset.Images.code.name
|
||||
case .codeBlock:
|
||||
return Asset.Images.codeBlock.name
|
||||
case .quote:
|
||||
return Asset.Images.quote.name
|
||||
case .link:
|
||||
return Asset.Images.link.name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,14 +89,26 @@ extension FormatItem {
|
||||
return "boldButton"
|
||||
case .italic:
|
||||
return "italicButton"
|
||||
case .strikethrough:
|
||||
return "strikethroughButton"
|
||||
case .underline:
|
||||
return "underlineButton"
|
||||
case .link:
|
||||
return "linkButton"
|
||||
case .strikethrough:
|
||||
return "strikethroughButton"
|
||||
case .unorderedList:
|
||||
return "unorderedListButton"
|
||||
case .orderedList:
|
||||
return "orderedListButton"
|
||||
case .indent:
|
||||
return "indentListButton"
|
||||
case .unIndent:
|
||||
return "unIndentButton"
|
||||
case .inlineCode:
|
||||
return "inlineCodeButton"
|
||||
case .codeBlock:
|
||||
return "codeBlockButton"
|
||||
case .quote:
|
||||
return "quoteButton"
|
||||
case .link:
|
||||
return "linkButton"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,14 +118,26 @@ extension FormatItem {
|
||||
return VectorL10n.wysiwygComposerFormatActionBold
|
||||
case .italic:
|
||||
return VectorL10n.wysiwygComposerFormatActionItalic
|
||||
case .strikethrough:
|
||||
return VectorL10n.wysiwygComposerFormatActionStrikethrough
|
||||
case .underline:
|
||||
return VectorL10n.wysiwygComposerFormatActionUnderline
|
||||
case .link:
|
||||
return VectorL10n.wysiwygComposerFormatActionLink
|
||||
case .strikethrough:
|
||||
return VectorL10n.wysiwygComposerFormatActionStrikethrough
|
||||
case .unorderedList:
|
||||
return VectorL10n.wysiwygComposerFormatActionUnorderedList
|
||||
case .orderedList:
|
||||
return VectorL10n.wysiwygComposerFormatActionOrderedList
|
||||
case .indent:
|
||||
return VectorL10n.wysiwygComposerFormatActionIndent
|
||||
case .unIndent:
|
||||
return VectorL10n.wysiwygComposerFormatActionUnIndent
|
||||
case .inlineCode:
|
||||
return VectorL10n.wysiwygComposerFormatActionInlineCode
|
||||
case .codeBlock:
|
||||
return VectorL10n.wysiwygComposerFormatActionCodeBlock
|
||||
case .quote:
|
||||
return VectorL10n.wysiwygComposerFormatActionQuote
|
||||
case .link:
|
||||
return VectorL10n.wysiwygComposerFormatActionLink
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,14 +150,26 @@ extension FormatType {
|
||||
return .bold
|
||||
case .italic:
|
||||
return .italic
|
||||
case .strikethrough:
|
||||
return .strikeThrough
|
||||
case .underline:
|
||||
return .underline
|
||||
case .link:
|
||||
return .link
|
||||
case .strikethrough:
|
||||
return .strikeThrough
|
||||
case .unorderedList:
|
||||
return .unorderedList
|
||||
case .orderedList:
|
||||
return .orderedList
|
||||
case .indent:
|
||||
return .indent
|
||||
case .unIndent:
|
||||
return .unIndent
|
||||
case .inlineCode:
|
||||
return .inlineCode
|
||||
case .codeBlock:
|
||||
return .codeBlock
|
||||
case .quote:
|
||||
return .quote
|
||||
case .link:
|
||||
return .link
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,14 +181,26 @@ extension FormatType {
|
||||
return .bold
|
||||
case .italic:
|
||||
return .italic
|
||||
case .strikethrough:
|
||||
return .strikeThrough
|
||||
case .underline:
|
||||
return .underline
|
||||
case .link:
|
||||
return .link
|
||||
case .strikethrough:
|
||||
return .strikeThrough
|
||||
case .unorderedList:
|
||||
return .unorderedList
|
||||
case .orderedList:
|
||||
return .orderedList
|
||||
case .indent:
|
||||
return .indent
|
||||
case .unIndent:
|
||||
return .unIndent
|
||||
case .inlineCode:
|
||||
return .inlineCode
|
||||
case .codeBlock:
|
||||
return .codeBlock
|
||||
case .quote:
|
||||
return .quote
|
||||
case .link:
|
||||
return .link
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,5 +233,3 @@ final class LinkActionWrapper: NSObject {
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -32,21 +32,23 @@ struct FormattingToolbar: View {
|
||||
var formatAction: (FormatType) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(formatItems) { item in
|
||||
Button {
|
||||
formatAction(item.type)
|
||||
} label: {
|
||||
Image(item.icon)
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(getForegroundColor(for: item))
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(formatItems) { item in
|
||||
Button {
|
||||
formatAction(item.type)
|
||||
} label: {
|
||||
Image(item.icon)
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(getForegroundColor(for: item))
|
||||
}
|
||||
.disabled(item.state == .disabled)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(getBackgroundColor(for: item))
|
||||
.cornerRadius(8)
|
||||
.accessibilityIdentifier(item.accessibilityIdentifier)
|
||||
.accessibilityLabel(item.accessibilityLabel)
|
||||
}
|
||||
.disabled(item.state == .disabled)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(getBackgroundColor(for: item))
|
||||
.cornerRadius(8)
|
||||
.accessibilityIdentifier(item.accessibilityIdentifier)
|
||||
.accessibilityLabel(item.accessibilityLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ final class RoomNotificationSettingsCoordinator: RoomNotificationSettingsCoordin
|
||||
)
|
||||
let avatarService: AvatarServiceProtocol = AvatarService(mediaManager: room.mxSession.mediaManager)
|
||||
let view = RoomNotificationSettings(viewModel: viewModel, presentedModally: presentedModally)
|
||||
.addDependency(avatarService)
|
||||
.environmentObject(AvatarViewModel(avatarService: avatarService))
|
||||
let viewController = VectorHostingController(rootView: view)
|
||||
roomNotificationSettingsViewModel = viewModel
|
||||
roomNotificationSettingsViewController = viewController
|
||||
|
||||
+11
-7
@@ -30,6 +30,10 @@ final class MXRoomNotificationSettingsService: RoomNotificationSettingsServiceTy
|
||||
|
||||
private var observers: [ObjectIdentifier] = []
|
||||
|
||||
private var notificationCenter: MXNotificationCenter? {
|
||||
room.mxSession?.notificationCenter
|
||||
}
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var notificationState: RoomNotificationState {
|
||||
@@ -166,7 +170,7 @@ final class MXRoomNotificationSettingsService: RoomNotificationSettingsServiceTy
|
||||
}
|
||||
handleFailureCallback(completion)
|
||||
|
||||
room.mxSession.notificationCenter.addRoomRule(
|
||||
notificationCenter?.addRoomRule(
|
||||
room.roomId,
|
||||
notify: false,
|
||||
sound: false,
|
||||
@@ -184,7 +188,7 @@ final class MXRoomNotificationSettingsService: RoomNotificationSettingsServiceTy
|
||||
}
|
||||
handleFailureCallback(completion)
|
||||
|
||||
room.mxSession.notificationCenter.addOverrideRule(
|
||||
notificationCenter?.addOverrideRule(
|
||||
withId: roomId,
|
||||
conditions: [["kind": "event_match", "key": "room_id", "pattern": roomId]],
|
||||
notify: false,
|
||||
@@ -196,11 +200,11 @@ final class MXRoomNotificationSettingsService: RoomNotificationSettingsServiceTy
|
||||
private func removePushRule(rule: MXPushRule, completion: @escaping Completion) {
|
||||
handleUpdateCallback(completion) { [weak self] in
|
||||
guard let self = self else { return true }
|
||||
return self.room.mxSession.notificationCenter.rule(byId: rule.ruleId) == nil
|
||||
return self.notificationCenter?.rule(byId: rule.ruleId) == nil
|
||||
}
|
||||
handleFailureCallback(completion)
|
||||
|
||||
room.mxSession.notificationCenter.removeRule(rule)
|
||||
notificationCenter?.removeRule(rule)
|
||||
}
|
||||
|
||||
private func enablePushRule(rule: MXPushRule, completion: @escaping Completion) {
|
||||
@@ -210,7 +214,7 @@ final class MXRoomNotificationSettingsService: RoomNotificationSettingsServiceTy
|
||||
}
|
||||
handleFailureCallback(completion)
|
||||
|
||||
room.mxSession.notificationCenter.enableRule(rule, isEnabled: true)
|
||||
notificationCenter?.enableRule(rule, isEnabled: true)
|
||||
}
|
||||
|
||||
private func handleUpdateCallback(_ completion: @escaping Completion, releaseCheck: @escaping () -> Bool) {
|
||||
@@ -283,14 +287,14 @@ private extension MXRoom {
|
||||
}
|
||||
|
||||
var overridePushRule: MXPushRule? {
|
||||
guard let overrideRules = mxSession.notificationCenter.rules.global.override else {
|
||||
guard let overrideRules = mxSession?.notificationCenter?.rules?.global?.override else {
|
||||
return nil
|
||||
}
|
||||
return getRoomRule(from: overrideRules)
|
||||
}
|
||||
|
||||
var roomPushRule: MXPushRule? {
|
||||
guard let roomRules = mxSession.notificationCenter.rules.global.room else {
|
||||
guard let roomRules = mxSession?.notificationCenter?.rules?.global?.room else {
|
||||
return nil
|
||||
}
|
||||
return getRoomRule(from: roomRules)
|
||||
|
||||
@@ -85,13 +85,13 @@ struct RoomNotificationSettings_Previews: PreviewProvider {
|
||||
NavigationView {
|
||||
RoomNotificationSettings(viewModel: mockViewModel, presentedModally: true)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.addDependency(MockAvatarService.example)
|
||||
.environmentObject(AvatarViewModel.withMockedServices())
|
||||
}
|
||||
NavigationView {
|
||||
RoomNotificationSettings(viewModel: mockViewModel, presentedModally: true)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.theme(ThemeIdentifier.dark)
|
||||
.addDependency(MockAvatarService.example)
|
||||
.environmentObject(AvatarViewModel.withMockedServices())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -43,6 +43,6 @@ struct RoomNotificationSettingsHeader_Previews: PreviewProvider {
|
||||
static let name = "Element"
|
||||
static var previews: some View {
|
||||
RoomNotificationSettingsHeader(avatarData: MockAvatarInput.example, displayName: name)
|
||||
.addDependency(MockAvatarService.example)
|
||||
.environmentObject(AvatarViewModel.withMockedServices())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import CommonKit
|
||||
import MatrixSDK
|
||||
import SwiftUI
|
||||
|
||||
struct PollHistoryCoordinatorParameters {
|
||||
let mode: PollHistoryMode
|
||||
let room: MXRoom
|
||||
let navigationRouter: NavigationRouterType
|
||||
}
|
||||
|
||||
final class PollHistoryCoordinator: NSObject, Coordinator, Presentable {
|
||||
private let parameters: PollHistoryCoordinatorParameters
|
||||
private let pollHistoryHostingController: UIViewController
|
||||
private var pollHistoryViewModel: PollHistoryViewModelProtocol
|
||||
private let navigationRouter: NavigationRouterType
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var completion: ((MXEvent) -> Void)?
|
||||
|
||||
init(parameters: PollHistoryCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
let viewModel = PollHistoryViewModel(mode: parameters.mode, pollService: PollHistoryService(room: parameters.room, chunkSizeInDays: PollHistoryConstants.chunkSizeInDays))
|
||||
let view = PollHistory(viewModel: viewModel.context)
|
||||
pollHistoryViewModel = viewModel
|
||||
pollHistoryHostingController = VectorHostingController(rootView: view)
|
||||
navigationRouter = parameters.navigationRouter
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("[PollHistoryCoordinator] did start.")
|
||||
pollHistoryViewModel.completion = { [weak self] result in
|
||||
switch result {
|
||||
case .showPollDetail(let poll):
|
||||
self?.showPollDetail(poll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showPollDetail(_ poll: TimelinePollDetails) {
|
||||
guard let event = parameters.room.mxSession.store.event(withEventId: poll.id, inRoom: parameters.room.roomId),
|
||||
let detailCoordinator: PollHistoryDetailCoordinator = try? .init(parameters: .init(event: event, poll: poll, room: parameters.room)) else {
|
||||
pollHistoryViewModel.context.alertInfo = .init(id: true, title: VectorL10n.settingsDiscoveryErrorMessage)
|
||||
return
|
||||
}
|
||||
detailCoordinator.toPresentable().presentationController?.delegate = self
|
||||
detailCoordinator.completion = { [weak self, weak detailCoordinator, weak event] result in
|
||||
guard let self, let coordinator = detailCoordinator, let event = event else { return }
|
||||
self.handlePollDetailResult(result, coordinator: coordinator, event: event, poll: poll)
|
||||
}
|
||||
|
||||
add(childCoordinator: detailCoordinator)
|
||||
detailCoordinator.start()
|
||||
toPresentable().present(detailCoordinator.toPresentable(), animated: true)
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
pollHistoryHostingController
|
||||
}
|
||||
|
||||
private func handlePollDetailResult(_ result: PollHistoryDetailViewModelResult, coordinator: Coordinator, event: MXEvent, poll: TimelinePollDetails) {
|
||||
switch result {
|
||||
case .dismiss:
|
||||
toPresentable().dismiss(animated: true)
|
||||
remove(childCoordinator: coordinator)
|
||||
case .viewInTimeline:
|
||||
toPresentable().dismiss(animated: false)
|
||||
remove(childCoordinator: coordinator)
|
||||
var event = event
|
||||
if poll.closed {
|
||||
let room = parameters.room
|
||||
let relatedEvents = room.mxSession.store.relations(forEvent: event.eventId, inRoom: room.roomId, relationType: MXEventRelationTypeReference)
|
||||
let pollEndedEvent = relatedEvents.first(where: { $0.eventType == .pollEnd })
|
||||
event = pollEndedEvent ?? event
|
||||
}
|
||||
completion?(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UIAdaptivePresentationControllerDelegate
|
||||
|
||||
extension PollHistoryCoordinator: UIAdaptivePresentationControllerDelegate {
|
||||
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
||||
guard let coordinator = childCoordinators.last else {
|
||||
return
|
||||
}
|
||||
remove(childCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Using an enum for the screen allows you define the different state cases with
|
||||
/// the relevant associated data for each case.
|
||||
enum MockPollHistoryScreenState: MockScreenState, CaseIterable {
|
||||
// A case for each state you want to represent
|
||||
// with specific, minimal associated data that will allow you
|
||||
// mock that screen.
|
||||
case active
|
||||
case past
|
||||
case activeNoMoreContent
|
||||
case contentLoading
|
||||
case empty
|
||||
case emptyLoading
|
||||
case emptyNoMoreContent
|
||||
case loading
|
||||
|
||||
/// The associated screen
|
||||
var screenType: Any.Type {
|
||||
PollHistory.self
|
||||
}
|
||||
|
||||
/// Generate the view struct for the screen state.
|
||||
var screenView: ([Any], AnyView) {
|
||||
var pollHistoryMode: PollHistoryMode = .active
|
||||
let pollService = MockPollHistoryService()
|
||||
|
||||
switch self {
|
||||
case .active:
|
||||
pollHistoryMode = .active
|
||||
case .activeNoMoreContent:
|
||||
pollHistoryMode = .active
|
||||
pollService.hasNextBatch = false
|
||||
case .past:
|
||||
pollHistoryMode = .past
|
||||
case .contentLoading:
|
||||
pollService.nextBatchPublishers.append(MockPollPublisher.loadingPolls)
|
||||
case .empty:
|
||||
pollHistoryMode = .active
|
||||
pollService.nextBatchPublishers = [MockPollPublisher.emptyPolls]
|
||||
case .emptyLoading:
|
||||
pollService.nextBatchPublishers = [MockPollPublisher.emptyPolls, MockPollPublisher.loadingPolls]
|
||||
case .emptyNoMoreContent:
|
||||
pollService.hasNextBatch = false
|
||||
pollService.nextBatchPublishers = [MockPollPublisher.emptyPolls]
|
||||
case .loading:
|
||||
pollService.nextBatchPublishers = [MockPollPublisher.loadingPolls]
|
||||
}
|
||||
|
||||
let viewModel = PollHistoryViewModel(mode: pollHistoryMode, pollService: pollService)
|
||||
|
||||
// can simulate service and viewModel actions here if needs be.
|
||||
switch self {
|
||||
case .contentLoading, .emptyLoading:
|
||||
viewModel.process(viewAction: .loadMoreContent)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
[pollHistoryMode, viewModel],
|
||||
AnyView(PollHistory(viewModel: viewModel.context)
|
||||
.environmentObject(AvatarViewModel.withMockedServices()))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum MockPollPublisher {
|
||||
static var emptyPolls: AnyPublisher<TimelinePollDetails, Error> {
|
||||
Empty<TimelinePollDetails, Error>(completeImmediately: true).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
static var loadingPolls: AnyPublisher<TimelinePollDetails, Error> {
|
||||
Empty<TimelinePollDetails, Error>(completeImmediately: false).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
static var failure: AnyPublisher<TimelinePollDetails, Error> {
|
||||
Fail(error: NSError(domain: "fake", code: 1)).eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import CommonKit
|
||||
import MatrixSDK
|
||||
import SwiftUI
|
||||
|
||||
struct PollHistoryDetailCoordinatorParameters {
|
||||
let event: MXEvent
|
||||
let poll: TimelinePollDetails
|
||||
let room: MXRoom
|
||||
}
|
||||
|
||||
final class PollHistoryDetailCoordinator: Coordinator, Presentable {
|
||||
private let parameters: PollHistoryDetailCoordinatorParameters
|
||||
private let pollHistoryDetailHostingController: UIViewController
|
||||
private var pollHistoryDetailViewModel: PollHistoryDetailViewModelProtocol
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var completion: ((PollHistoryDetailViewModelResult) -> Void)?
|
||||
|
||||
init(parameters: PollHistoryDetailCoordinatorParameters) throws {
|
||||
self.parameters = parameters
|
||||
let timelinePollCoordinator = try TimelinePollCoordinator(parameters: .init(session: parameters.room.mxSession, room: parameters.room, pollEvent: parameters.event))
|
||||
|
||||
let viewModel = PollHistoryDetailViewModel(poll: parameters.poll)
|
||||
let view = PollHistoryDetail(viewModel: viewModel.context, contentPoll: timelinePollCoordinator.toView())
|
||||
pollHistoryDetailViewModel = viewModel
|
||||
pollHistoryDetailHostingController = VectorHostingController(rootView: view)
|
||||
add(childCoordinator: timelinePollCoordinator)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("[PollHistoryDetailCoordinator] did start.")
|
||||
pollHistoryDetailViewModel.completion = { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .dismiss:
|
||||
self.completion?(.dismiss)
|
||||
case .viewInTimeline:
|
||||
self.completion?(.viewInTimeline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
pollHistoryDetailHostingController
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum MockPollHistoryDetailScreenState: MockScreenState, CaseIterable {
|
||||
case openDisclosed
|
||||
case closedDisclosed
|
||||
case openUndisclosed
|
||||
case closedUndisclosed
|
||||
case closedPollEnded
|
||||
|
||||
var screenType: Any.Type {
|
||||
PollHistoryDetail.self
|
||||
}
|
||||
|
||||
var poll: TimelinePollDetails {
|
||||
let answerOptions = [TimelinePollAnswerOption(id: "1", text: "First", count: 10, winner: false, selected: false),
|
||||
TimelinePollAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true),
|
||||
TimelinePollAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false)]
|
||||
|
||||
let poll = TimelinePollDetails(id: "id",
|
||||
question: "Question",
|
||||
answerOptions: answerOptions,
|
||||
closed: self == .closedDisclosed || self == .closedUndisclosed ? true : false,
|
||||
startDate: .init(timeIntervalSinceReferenceDate: 0),
|
||||
totalAnswerCount: 20,
|
||||
type: self == .closedDisclosed || self == .openDisclosed ? .disclosed : .undisclosed,
|
||||
eventType: self == .closedPollEnded ? .ended : .started,
|
||||
maxAllowedSelections: 1,
|
||||
hasBeenEdited: false,
|
||||
hasDecryptionError: false)
|
||||
return poll
|
||||
}
|
||||
|
||||
var screenView: ([Any], AnyView) {
|
||||
let timelineViewModel = TimelinePollViewModel(timelinePollDetails: poll)
|
||||
let viewModel = PollHistoryDetailViewModel(poll: poll)
|
||||
|
||||
return ([viewModel], AnyView(PollHistoryDetail(viewModel: viewModel.context, contentPoll: TimelinePollView(viewModel: timelineViewModel.context))))
|
||||
}
|
||||
}
|
||||
+17
-15
@@ -15,29 +15,31 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
// MARK: View model
|
||||
typealias PollHistoryDetailViewModelCallback = (PollHistoryDetailViewModelResult) -> Void
|
||||
|
||||
enum AllChatsOnboardingViewModelResult {
|
||||
case cancel
|
||||
enum PollHistoryDetailViewModelResult {
|
||||
case dismiss
|
||||
case viewInTimeline
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct AllChatsOnboardingPageData: Identifiable {
|
||||
let id = UUID().uuidString
|
||||
let image: UIImage
|
||||
let title: String
|
||||
let message: String
|
||||
struct PollHistoryDetailViewState: BindableState {
|
||||
var poll: TimelinePollDetails
|
||||
var pollStartDate: Date {
|
||||
poll.startDate
|
||||
}
|
||||
|
||||
var isPollClosed: Bool {
|
||||
poll.closed
|
||||
}
|
||||
}
|
||||
|
||||
struct AllChatsOnboardingViewState: BindableState {
|
||||
let pages: [AllChatsOnboardingPageData]
|
||||
}
|
||||
|
||||
enum AllChatsOnboardingViewAction {
|
||||
case cancel
|
||||
enum PollHistoryDetailViewAction {
|
||||
case dismiss
|
||||
case viewInTimeline
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias PollHistoryDetailViewModelType = StateStoreViewModel<PollHistoryDetailViewState, PollHistoryDetailViewAction>
|
||||
|
||||
class PollHistoryDetailViewModel: PollHistoryDetailViewModelType, PollHistoryDetailViewModelProtocol {
|
||||
// MARK: Public
|
||||
|
||||
var completion: PollHistoryDetailViewModelCallback?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(poll: TimelinePollDetails) {
|
||||
super.init(initialViewState: PollHistoryDetailViewState(poll: poll))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: PollHistoryDetailViewAction) {
|
||||
switch viewAction {
|
||||
case .dismiss:
|
||||
completion?(.dismiss)
|
||||
case .viewInTimeline:
|
||||
completion?(.viewInTimeline)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -16,8 +16,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol AllChatsOnboardingViewModelProtocol {
|
||||
var completion: ((AllChatsOnboardingViewModelResult) -> Void)? { get set }
|
||||
static func makeAllChatsOnboardingViewModel() -> AllChatsOnboardingViewModelProtocol
|
||||
var context: AllChatsOnboardingViewModelType.Context { get }
|
||||
protocol PollHistoryDetailViewModelProtocol {
|
||||
var completion: PollHistoryDetailViewModelCallback? { get set }
|
||||
var context: PollHistoryDetailViewModelType.Context { get }
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import RiotSwiftUI
|
||||
import XCTest
|
||||
|
||||
class PollHistoryDetailUITests: MockScreenTestCase {
|
||||
func testPollHistoryDetailOpenPoll() {
|
||||
app.goToScreenWithIdentifier(MockPollHistoryDetailScreenState.openDisclosed.title)
|
||||
let title = app.navigationBars.staticTexts.firstMatch.label
|
||||
XCTAssertEqual(title, VectorL10n.pollHistoryActiveSegmentTitle)
|
||||
XCTAssertEqual(app.staticTexts["PollHistoryDetail.date"].label, "1/1/01")
|
||||
XCTAssertEqual(app.buttons["PollHistoryDetail.viewInTimeLineButton"].label, VectorL10n.pollHistoryDetailViewInTimeline)
|
||||
}
|
||||
|
||||
func testPollHistoryDetailClosedPoll() {
|
||||
app.goToScreenWithIdentifier(MockPollHistoryDetailScreenState.closedDisclosed.title)
|
||||
let title = app.navigationBars.staticTexts.firstMatch.label
|
||||
XCTAssertEqual(title, VectorL10n.pollHistoryPastSegmentTitle)
|
||||
XCTAssertEqual(app.staticTexts["PollHistoryDetail.date"].label, "1/1/01")
|
||||
XCTAssertEqual(app.buttons["PollHistoryDetail.viewInTimeLineButton"].label, VectorL10n.pollHistoryDetailViewInTimeline)
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import RiotSwiftUI
|
||||
|
||||
class PollHistoryDetailViewModelTests: XCTestCase {
|
||||
private enum Constants {
|
||||
static let counterInitialValue = 0
|
||||
}
|
||||
|
||||
var viewModel: PollHistoryDetailViewModel!
|
||||
var context: PollHistoryDetailViewModelType.Context!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
let answerOptions = [TimelinePollAnswerOption(id: "1", text: "1", count: 1, winner: false, selected: false),
|
||||
TimelinePollAnswerOption(id: "2", text: "2", count: 1, winner: false, selected: false),
|
||||
TimelinePollAnswerOption(id: "3", text: "3", count: 1, winner: false, selected: false)]
|
||||
|
||||
let timelinePoll = TimelinePollDetails(id: "poll-id",
|
||||
question: "Question",
|
||||
answerOptions: answerOptions,
|
||||
closed: false,
|
||||
startDate: .init(),
|
||||
totalAnswerCount: 3,
|
||||
type: .disclosed,
|
||||
eventType: .started,
|
||||
maxAllowedSelections: 1,
|
||||
hasBeenEdited: false,
|
||||
hasDecryptionError: false)
|
||||
|
||||
viewModel = PollHistoryDetailViewModel(poll: timelinePoll)
|
||||
context = viewModel.context
|
||||
}
|
||||
|
||||
func testInitialState() {
|
||||
XCTAssertFalse(context.viewState.isPollClosed)
|
||||
}
|
||||
|
||||
func testProcessAction() {
|
||||
viewModel.completion = { result in
|
||||
XCTAssertEqual(result, .viewInTimeline)
|
||||
}
|
||||
viewModel.process(viewAction: .viewInTimeline)
|
||||
}
|
||||
|
||||
func testProcessDismiss() {
|
||||
viewModel.completion = { result in
|
||||
XCTAssertEqual(result, .dismiss)
|
||||
}
|
||||
viewModel.process(viewAction: .dismiss)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PollHistoryDetail: View {
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var viewModel: PollHistoryDetailViewModel.Context
|
||||
var contentPoll: any View
|
||||
|
||||
var body: some View {
|
||||
navigation
|
||||
}
|
||||
|
||||
private var navigation: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
return NavigationStack {
|
||||
content
|
||||
}
|
||||
} else {
|
||||
return NavigationView {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text(DateFormatter.pollShortDateFormatter.string(from: viewModel.viewState.pollStartDate))
|
||||
.foregroundColor(theme.colors.tertiaryContent)
|
||||
.font(theme.fonts.caption1)
|
||||
.padding([.top])
|
||||
.accessibilityIdentifier("PollHistoryDetail.date")
|
||||
AnyView(contentPoll)
|
||||
.navigationTitle(navigationTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.navigationBarItems(leading: backButton, trailing: doneButton)
|
||||
viewInTimeline
|
||||
}
|
||||
}
|
||||
.padding([.horizontal], 16)
|
||||
.padding([.top, .bottom])
|
||||
.background(theme.colors.background.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private var backButton: some View {
|
||||
Button(action: {
|
||||
viewModel.send(viewAction: .dismiss)
|
||||
}) {
|
||||
Image(systemName: "chevron.left")
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.foregroundColor(theme.colors.accent)
|
||||
}
|
||||
}
|
||||
|
||||
private var doneButton: some View {
|
||||
Button {
|
||||
viewModel.send(viewAction: .dismiss)
|
||||
} label: {
|
||||
Text(VectorL10n.done)
|
||||
}
|
||||
.accentColor(theme.colors.accent)
|
||||
}
|
||||
|
||||
private var viewInTimeline: some View {
|
||||
Button {
|
||||
viewModel.send(viewAction: .viewInTimeline)
|
||||
} label: {
|
||||
Text(VectorL10n.pollHistoryDetailViewInTimeline)
|
||||
}
|
||||
.accentColor(theme.colors.accent)
|
||||
.accessibilityIdentifier("PollHistoryDetail.viewInTimeLineButton")
|
||||
}
|
||||
|
||||
private var navigationTitle: String {
|
||||
if viewModel.viewState.isPollClosed {
|
||||
return VectorL10n.pollHistoryPastSegmentTitle
|
||||
} else {
|
||||
return VectorL10n.pollHistoryActiveSegmentTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct PollHistoryDetail_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockPollHistoryDetailScreenState.stateRenderer
|
||||
static var previews: some View {
|
||||
stateRenderer.screenGroup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum PollHistoryConstants {
|
||||
static let chunkSizeInDays: UInt = 30
|
||||
}
|
||||
|
||||
enum PollHistoryViewModelResult {
|
||||
case showPollDetail(poll: TimelinePollDetails)
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
enum PollHistoryMode: CaseIterable {
|
||||
case active
|
||||
case past
|
||||
}
|
||||
|
||||
struct PollHistoryViewBindings {
|
||||
var mode: PollHistoryMode
|
||||
var alertInfo: AlertInfo<Bool>?
|
||||
}
|
||||
|
||||
struct PollHistoryViewState: BindableState {
|
||||
init(mode: PollHistoryMode) {
|
||||
bindings = .init(mode: mode)
|
||||
}
|
||||
|
||||
var bindings: PollHistoryViewBindings
|
||||
var isLoading = false
|
||||
var canLoadMoreContent = true
|
||||
var polls: [TimelinePollDetails]?
|
||||
var syncStartDate: Date = .init()
|
||||
var syncedUpTo: Date = .distantFuture
|
||||
}
|
||||
|
||||
enum PollHistoryViewAction {
|
||||
case viewAppeared
|
||||
case segmentDidChange
|
||||
case showPollDetail(poll: TimelinePollDetails)
|
||||
case loadMoreContent
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias PollHistoryViewModelType = StateStoreViewModel<PollHistoryViewState, PollHistoryViewAction>
|
||||
|
||||
final class PollHistoryViewModel: PollHistoryViewModelType, PollHistoryViewModelProtocol {
|
||||
private let pollService: PollHistoryServiceProtocol
|
||||
private var polls: [TimelinePollDetails]?
|
||||
private var subcriptions: Set<AnyCancellable> = .init()
|
||||
|
||||
var completion: ((PollHistoryViewModelResult) -> Void)?
|
||||
|
||||
init(mode: PollHistoryMode, pollService: PollHistoryServiceProtocol) {
|
||||
self.pollService = pollService
|
||||
super.init(initialViewState: PollHistoryViewState(mode: mode))
|
||||
state.canLoadMoreContent = pollService.hasNextBatch
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: PollHistoryViewAction) {
|
||||
switch viewAction {
|
||||
case .viewAppeared:
|
||||
setupUpdateSubscriptions()
|
||||
fetchContent()
|
||||
case .segmentDidChange:
|
||||
updateViewState()
|
||||
case .showPollDetail(let poll):
|
||||
completion?(.showPollDetail(poll: poll))
|
||||
case .loadMoreContent:
|
||||
fetchContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension PollHistoryViewModel {
|
||||
func fetchContent() {
|
||||
state.isLoading = true
|
||||
|
||||
pollService
|
||||
.nextBatch()
|
||||
.collect()
|
||||
.sink { [weak self] completion in
|
||||
self?.handleBatchEnded(completion: completion)
|
||||
} receiveValue: { [weak self] polls in
|
||||
self?.add(polls: polls)
|
||||
}
|
||||
.store(in: &subcriptions)
|
||||
}
|
||||
|
||||
func handleBatchEnded(completion: Subscribers.Completion<Error>) {
|
||||
state.isLoading = false
|
||||
state.canLoadMoreContent = pollService.hasNextBatch
|
||||
|
||||
switch completion {
|
||||
case .finished:
|
||||
break
|
||||
case .failure:
|
||||
polls = polls ?? []
|
||||
state.bindings.alertInfo = .init(id: true, title: VectorL10n.pollHistoryFetchingError)
|
||||
}
|
||||
|
||||
updateViewState()
|
||||
}
|
||||
|
||||
func setupUpdateSubscriptions() {
|
||||
subcriptions.removeAll()
|
||||
|
||||
pollService
|
||||
.updates
|
||||
.sink { [weak self] detail in
|
||||
self?.update(poll: detail)
|
||||
self?.updateViewState()
|
||||
}
|
||||
.store(in: &subcriptions)
|
||||
|
||||
pollService
|
||||
.fetchedUpTo
|
||||
.weakAssign(to: \.state.syncedUpTo, on: self)
|
||||
.store(in: &subcriptions)
|
||||
|
||||
pollService
|
||||
.livePolls
|
||||
.sink { [weak self] livePoll in
|
||||
self?.add(polls: [livePoll])
|
||||
self?.updateViewState()
|
||||
}
|
||||
.store(in: &subcriptions)
|
||||
}
|
||||
|
||||
func update(poll: TimelinePollDetails) {
|
||||
guard let pollIndex = polls?.firstIndex(where: { $0.id == poll.id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
polls?[pollIndex] = poll
|
||||
}
|
||||
|
||||
func add(polls: [TimelinePollDetails]) {
|
||||
self.polls = (self.polls ?? []) + polls
|
||||
}
|
||||
|
||||
func updateViewState() {
|
||||
let renderedPolls: [TimelinePollDetails]?
|
||||
|
||||
switch context.mode {
|
||||
case .active:
|
||||
renderedPolls = polls?.filter { $0.closed == false }
|
||||
case .past:
|
||||
renderedPolls = polls?.filter { $0.closed == true }
|
||||
}
|
||||
|
||||
state.polls = renderedPolls?.sorted(by: { $0.startDate > $1.startDate })
|
||||
}
|
||||
}
|
||||
|
||||
extension PollHistoryViewModel.Context {
|
||||
var emptyPollsText: String {
|
||||
switch (viewState.bindings.mode, viewState.canLoadMoreContent) {
|
||||
case (.active, true):
|
||||
return VectorL10n.pollHistoryNoActivePollPeriodText("\(syncedPastDays)")
|
||||
case (.active, false):
|
||||
return VectorL10n.pollHistoryNoActivePollText
|
||||
case (.past, true):
|
||||
return VectorL10n.pollHistoryNoPastPollPeriodText("\(syncedPastDays)")
|
||||
case (.past, false):
|
||||
return VectorL10n.pollHistoryNoPastPollText
|
||||
}
|
||||
}
|
||||
|
||||
var syncedPastDays: Int {
|
||||
guard let days = Calendar.current.dateComponents([.day], from: viewState.syncedUpTo, to: viewState.syncStartDate).day else {
|
||||
return 0
|
||||
}
|
||||
return max(0, days)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol PollHistoryViewModelProtocol {
|
||||
var completion: ((PollHistoryViewModelResult) -> Void)? { get set }
|
||||
var context: PollHistoryViewModelType.Context { get }
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import MatrixSDK
|
||||
|
||||
final class PollHistoryService: PollHistoryServiceProtocol {
|
||||
private let room: MXRoom
|
||||
private let timeline: MXEventTimeline
|
||||
private let chunkSizeInDays: UInt
|
||||
|
||||
private var timelineListener: Any?
|
||||
private var roomListener: Any?
|
||||
|
||||
// polls aggregation
|
||||
private var pollAggregationContexts: [String: PollAggregationContext] = [:]
|
||||
|
||||
// polls
|
||||
private var currentBatchSubject: PassthroughSubject<TimelinePollDetails, Error>?
|
||||
private var livePollsSubject: PassthroughSubject<TimelinePollDetails, Never> = .init()
|
||||
|
||||
// polls updates
|
||||
private let updatesSubject: PassthroughSubject<TimelinePollDetails, Never> = .init()
|
||||
|
||||
// timestamps
|
||||
private var targetTimestamp: Date = .init()
|
||||
private var oldestEventDateSubject: CurrentValueSubject<Date, Never> = .init(.init())
|
||||
|
||||
var updates: AnyPublisher<TimelinePollDetails, Never> {
|
||||
updatesSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(room: MXRoom, chunkSizeInDays: UInt) {
|
||||
self.room = room
|
||||
self.chunkSizeInDays = chunkSizeInDays
|
||||
timeline = MXRoomEventTimeline(room: room, andInitialEventId: nil)
|
||||
setupTimeline()
|
||||
setupLiveUpdates()
|
||||
}
|
||||
|
||||
func nextBatch() -> AnyPublisher<TimelinePollDetails, Error> {
|
||||
currentBatchSubject?.eraseToAnyPublisher() ?? startPagination()
|
||||
}
|
||||
|
||||
var hasNextBatch: Bool {
|
||||
timeline.canPaginate(.backwards)
|
||||
}
|
||||
|
||||
var fetchedUpTo: AnyPublisher<Date, Never> {
|
||||
oldestEventDateSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
var livePolls: AnyPublisher<TimelinePollDetails, Never> {
|
||||
livePollsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
deinit {
|
||||
guard let roomListener = roomListener else {
|
||||
return
|
||||
}
|
||||
room.removeListener(roomListener)
|
||||
}
|
||||
|
||||
class PollAggregationContext {
|
||||
var pollAggregator: PollAggregator?
|
||||
let isLivePoll: Bool
|
||||
var published: Bool
|
||||
|
||||
init(pollAggregator: PollAggregator? = nil, isLivePoll: Bool, published: Bool = false) {
|
||||
self.pollAggregator = pollAggregator
|
||||
self.isLivePoll = isLivePoll
|
||||
self.published = published
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension PollHistoryService {
|
||||
enum Constants {
|
||||
static let pageSize: UInt = 250
|
||||
}
|
||||
|
||||
func setupTimeline() {
|
||||
timeline.resetPagination()
|
||||
|
||||
timelineListener = timeline.listenToEvents { [weak self] event, _, _ in
|
||||
if event.eventType == .pollStart {
|
||||
self?.aggregatePoll(pollStartEvent: event, isLivePoll: false)
|
||||
}
|
||||
|
||||
self?.updateTimestamp(event: event)
|
||||
}
|
||||
}
|
||||
|
||||
func setupLiveUpdates() {
|
||||
roomListener = room.listen(toEventsOfTypes: [kMXEventTypeStringPollStart, kMXEventTypeStringPollStartMSC3381]) { [weak self] event, _, _ in
|
||||
if event.eventType == .pollStart {
|
||||
self?.aggregatePoll(pollStartEvent: event, isLivePoll: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateTimestamp(event: MXEvent) {
|
||||
oldestEventDate = min(event.originServerDate, oldestEventDate)
|
||||
}
|
||||
|
||||
func startPagination() -> AnyPublisher<TimelinePollDetails, Error> {
|
||||
let startingTimestamp = oldestEventDate
|
||||
targetTimestamp = startingTimestamp.subtractingDays(chunkSizeInDays) ?? startingTimestamp
|
||||
|
||||
let batchSubject = PassthroughSubject<TimelinePollDetails, Error>()
|
||||
currentBatchSubject = batchSubject
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
self.paginate()
|
||||
}
|
||||
|
||||
return batchSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func paginate() {
|
||||
timeline.paginate(Constants.pageSize, direction: .backwards, onlyFromStore: false) { [weak self] response in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch response {
|
||||
case .success:
|
||||
if self.timeline.canPaginate(.backwards), self.timestampTargetReached == false {
|
||||
self.paginate()
|
||||
} else {
|
||||
self.completeBatch(completion: .finished)
|
||||
}
|
||||
case .failure(let error):
|
||||
self.completeBatch(completion: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func completeBatch(completion: Subscribers.Completion<Error>) {
|
||||
currentBatchSubject?.send(completion: completion)
|
||||
currentBatchSubject = nil
|
||||
}
|
||||
|
||||
func aggregatePoll(pollStartEvent: MXEvent, isLivePoll: Bool) {
|
||||
let eventId: String = pollStartEvent.eventId
|
||||
|
||||
guard pollAggregationContexts[eventId] == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
let newContext: PollAggregationContext = .init(isLivePoll: isLivePoll)
|
||||
pollAggregationContexts[eventId] = newContext
|
||||
|
||||
do {
|
||||
newContext.pollAggregator = try PollAggregator(session: room.mxSession, room: room, pollEvent: pollStartEvent, delegate: self)
|
||||
} catch {
|
||||
pollAggregationContexts.removeValue(forKey: eventId)
|
||||
}
|
||||
}
|
||||
|
||||
var timestampTargetReached: Bool {
|
||||
oldestEventDate <= targetTimestamp
|
||||
}
|
||||
|
||||
var oldestEventDate: Date {
|
||||
get {
|
||||
oldestEventDateSubject.value
|
||||
}
|
||||
set {
|
||||
oldestEventDateSubject.send(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Date {
|
||||
func subtractingDays(_ days: UInt) -> Date? {
|
||||
Calendar.current.date(byAdding: DateComponents(day: -Int(days)), to: self)
|
||||
}
|
||||
}
|
||||
|
||||
private extension MXEvent {
|
||||
var originServerDate: Date {
|
||||
.init(timeIntervalSince1970: Double(originServerTs) / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PollAggregatorDelegate
|
||||
|
||||
extension PollHistoryService: PollAggregatorDelegate {
|
||||
func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) { }
|
||||
|
||||
func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { }
|
||||
|
||||
func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) {
|
||||
guard let context = pollAggregationContexts[aggregator.poll.id], context.published == false else {
|
||||
return
|
||||
}
|
||||
|
||||
context.published = true
|
||||
|
||||
let newPoll: TimelinePollDetails = .init(poll: aggregator.poll, represent: .started)
|
||||
|
||||
if context.isLivePoll {
|
||||
livePollsSubject.send(newPoll)
|
||||
} else {
|
||||
currentBatchSubject?.send(newPoll)
|
||||
}
|
||||
}
|
||||
|
||||
func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) {
|
||||
guard let context = pollAggregationContexts[aggregator.poll.id], context.published else {
|
||||
return
|
||||
}
|
||||
updatesSubject.send(.init(poll: aggregator.poll, represent: .started))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
final class MockPollHistoryService: PollHistoryServiceProtocol {
|
||||
lazy var nextBatchPublishers: [AnyPublisher<TimelinePollDetails, Error>] = [
|
||||
(activePollsData + pastPollsData)
|
||||
.publisher
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
]
|
||||
|
||||
func nextBatch() -> AnyPublisher<TimelinePollDetails, Error> {
|
||||
nextBatchPublishers.isEmpty ? Empty().eraseToAnyPublisher() : nextBatchPublishers.removeFirst()
|
||||
}
|
||||
|
||||
var updatesPublisher: AnyPublisher<TimelinePollDetails, Never> = Empty().eraseToAnyPublisher()
|
||||
var updates: AnyPublisher<TimelinePollDetails, Never> {
|
||||
updatesPublisher
|
||||
}
|
||||
|
||||
var hasNextBatch = true
|
||||
|
||||
var fetchedUpToPublisher: AnyPublisher<Date, Never> = Just(.init()).eraseToAnyPublisher()
|
||||
var fetchedUpTo: AnyPublisher<Date, Never> {
|
||||
fetchedUpToPublisher
|
||||
}
|
||||
|
||||
var livePollsPublisher: AnyPublisher<TimelinePollDetails, Never> = Empty().eraseToAnyPublisher()
|
||||
var livePolls: AnyPublisher<TimelinePollDetails, Never> {
|
||||
livePollsPublisher
|
||||
}
|
||||
}
|
||||
|
||||
private extension MockPollHistoryService {
|
||||
var activePollsData: [TimelinePollDetails] {
|
||||
(1...3)
|
||||
.map { index in
|
||||
TimelinePollDetails(id: "a\(index)",
|
||||
question: "Do you like the active poll number \(index)?",
|
||||
answerOptions: [],
|
||||
closed: false,
|
||||
startDate: .init().addingTimeInterval(TimeInterval(-index) * 3600 * 24),
|
||||
totalAnswerCount: 30,
|
||||
type: .disclosed,
|
||||
eventType: .started,
|
||||
maxAllowedSelections: 1,
|
||||
hasBeenEdited: false,
|
||||
hasDecryptionError: false)
|
||||
}
|
||||
}
|
||||
|
||||
var pastPollsData: [TimelinePollDetails] {
|
||||
(1...3)
|
||||
.map { index in
|
||||
TimelinePollDetails(id: "p\(index)",
|
||||
question: "Do you like the active poll number \(index)?",
|
||||
answerOptions: [.init(id: "id", text: "Yes, of course!", count: 20, winner: true, selected: true)],
|
||||
closed: true,
|
||||
startDate: .init().addingTimeInterval(TimeInterval(-index) * 3600 * 24),
|
||||
totalAnswerCount: 30,
|
||||
type: .disclosed,
|
||||
eventType: .started,
|
||||
maxAllowedSelections: 1,
|
||||
hasBeenEdited: false,
|
||||
hasDecryptionError: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
protocol PollHistoryServiceProtocol {
|
||||
/// Returns a Publisher publishing the polls in the next batch.
|
||||
/// Implementations should return the same publisher if `nextBatch()` is called again before the previous publisher completes.
|
||||
func nextBatch() -> AnyPublisher<TimelinePollDetails, Error>
|
||||
|
||||
/// Publishes updates for the polls previously pusblished by the `nextBatch()` or `livePolls` publishers.
|
||||
var updates: AnyPublisher<TimelinePollDetails, Never> { get }
|
||||
|
||||
/// Publishes live polls not related with the current batch.
|
||||
var livePolls: AnyPublisher<TimelinePollDetails, Never> { get }
|
||||
|
||||
/// Returns true every time the service can fetch another batch.
|
||||
/// There is no guarantee the `nextBatch()` returned publisher will publish something anyway.
|
||||
var hasNextBatch: Bool { get }
|
||||
|
||||
/// Publishes the date up to the service is synced (in the past).
|
||||
/// This date doesn't need to be related with any poll event.
|
||||
var fetchedUpTo: AnyPublisher<Date, Never> { get }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import RiotSwiftUI
|
||||
import XCTest
|
||||
|
||||
final class PollHistoryUITests: MockScreenTestCase {
|
||||
func testActivePollHistoryHasContent() {
|
||||
app.goToScreenWithIdentifier(MockPollHistoryScreenState.active.title)
|
||||
let title = app.navigationBars.firstMatch.identifier
|
||||
let emptyText = app.staticTexts["PollHistory.emptyText"]
|
||||
let items = app.staticTexts["PollListItem.title"]
|
||||
let selectedSegment = app.buttons[VectorL10n.pollHistoryActiveSegmentTitle]
|
||||
let loadMoreButton = app.buttons["PollHistory.loadMore"]
|
||||
let winningOption = app.staticTexts["PollListData.winningOption"]
|
||||
|
||||
XCTAssertEqual(title, VectorL10n.pollHistoryTitle)
|
||||
XCTAssertTrue(items.exists)
|
||||
XCTAssertFalse(emptyText.exists)
|
||||
XCTAssertTrue(selectedSegment.exists)
|
||||
XCTAssertEqual(selectedSegment.value as? String, VectorL10n.accessibilitySelected)
|
||||
XCTAssertTrue(loadMoreButton.exists)
|
||||
XCTAssertFalse(winningOption.exists)
|
||||
}
|
||||
|
||||
func testPastPollHistoryHasContent() {
|
||||
app.goToScreenWithIdentifier(MockPollHistoryScreenState.past.title)
|
||||
let title = app.navigationBars.firstMatch.identifier
|
||||
let emptyText = app.staticTexts["PollHistory.emptyText"]
|
||||
let items = app.staticTexts["PollListItem.title"]
|
||||
let selectedSegment = app.buttons[VectorL10n.pollHistoryPastSegmentTitle]
|
||||
let loadMoreButton = app.buttons["PollHistory.loadMore"]
|
||||
let winningOption = app.buttons["PollAnswerOption0"]
|
||||
|
||||
XCTAssertEqual(title, VectorL10n.pollHistoryTitle)
|
||||
XCTAssertTrue(items.exists)
|
||||
XCTAssertFalse(emptyText.exists)
|
||||
XCTAssertTrue(selectedSegment.exists)
|
||||
XCTAssertEqual(selectedSegment.value as? String, VectorL10n.accessibilitySelected)
|
||||
XCTAssertTrue(loadMoreButton.exists)
|
||||
XCTAssertTrue(winningOption.exists)
|
||||
}
|
||||
|
||||
func testActivePollHistoryHasContentAndCantLoadMore() {
|
||||
app.goToScreenWithIdentifier(MockPollHistoryScreenState.activeNoMoreContent.title)
|
||||
let emptyText = app.staticTexts["PollHistory.emptyText"]
|
||||
let items = app.staticTexts["PollListItem.title"]
|
||||
let loadMoreButton = app.buttons["PollHistory.loadMore"]
|
||||
|
||||
XCTAssertTrue(items.exists)
|
||||
XCTAssertFalse(emptyText.exists)
|
||||
XCTAssertFalse(loadMoreButton.exists)
|
||||
}
|
||||
|
||||
func testActivePollHistoryHasContentAndCanLoadMore() {
|
||||
app.goToScreenWithIdentifier(MockPollHistoryScreenState.contentLoading.title)
|
||||
let title = app.navigationBars.firstMatch.identifier
|
||||
let emptyText = app.staticTexts["PollHistory.emptyText"]
|
||||
let items = app.staticTexts["PollListItem.title"]
|
||||
let loadMoreButton = app.buttons["PollHistory.loadMore"]
|
||||
|
||||
XCTAssertTrue(items.exists)
|
||||
XCTAssertFalse(emptyText.exists)
|
||||
XCTAssertTrue(loadMoreButton.exists)
|
||||
XCTAssertFalse(loadMoreButton.isEnabled)
|
||||
}
|
||||
|
||||
func testActivePollHistoryEmptyAndCanLoadMore() {
|
||||
app.goToScreenWithIdentifier(MockPollHistoryScreenState.empty.title)
|
||||
let emptyText = app.staticTexts["PollHistory.emptyText"]
|
||||
let items = app.staticTexts["PollListItem.title"]
|
||||
let loadMoreButton = app.buttons["PollHistory.loadMore"]
|
||||
|
||||
XCTAssertFalse(items.exists)
|
||||
XCTAssertTrue(emptyText.exists)
|
||||
XCTAssertTrue(loadMoreButton.exists)
|
||||
XCTAssertTrue(loadMoreButton.isEnabled)
|
||||
}
|
||||
|
||||
func testActivePollHistoryEmptyAndLoading() {
|
||||
app.goToScreenWithIdentifier(MockPollHistoryScreenState.emptyLoading.title)
|
||||
let emptyText = app.staticTexts["PollHistory.emptyText"]
|
||||
let items = app.staticTexts["PollListItem.title"]
|
||||
let loadMoreButton = app.buttons["PollHistory.loadMore"]
|
||||
|
||||
XCTAssertFalse(items.exists)
|
||||
XCTAssertTrue(emptyText.exists)
|
||||
XCTAssertTrue(loadMoreButton.exists)
|
||||
XCTAssertFalse(loadMoreButton.isEnabled)
|
||||
}
|
||||
|
||||
func testActivePollHistoryEmptyAndCantLoadMore() {
|
||||
app.goToScreenWithIdentifier(MockPollHistoryScreenState.emptyNoMoreContent.title)
|
||||
let emptyText = app.staticTexts["PollHistory.emptyText"]
|
||||
let items = app.staticTexts["PollListItem.title"]
|
||||
let loadMoreButton = app.buttons["PollHistory.loadMore"]
|
||||
|
||||
XCTAssertFalse(items.exists)
|
||||
XCTAssertTrue(emptyText.exists)
|
||||
XCTAssertFalse(loadMoreButton.exists)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
@testable import RiotSwiftUI
|
||||
import XCTest
|
||||
|
||||
final class PollHistoryViewModelTests: XCTestCase {
|
||||
private var viewModel: PollHistoryViewModel!
|
||||
private var pollHistoryService: MockPollHistoryService = .init()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
pollHistoryService = .init()
|
||||
viewModel = .init(mode: .active, pollService: pollHistoryService)
|
||||
}
|
||||
|
||||
func testEmitsContentOnLanding() throws {
|
||||
XCTAssert(viewModel.state.polls == nil)
|
||||
viewModel.process(viewAction: .viewAppeared)
|
||||
XCTAssertFalse(try polls.isEmpty)
|
||||
}
|
||||
|
||||
func testLoadingState() throws {
|
||||
XCTAssertFalse(viewModel.state.isLoading)
|
||||
viewModel.process(viewAction: .viewAppeared)
|
||||
XCTAssertFalse(viewModel.state.isLoading)
|
||||
XCTAssertFalse(try polls.isEmpty)
|
||||
}
|
||||
|
||||
func testLoadingStateIsTrueWhileLoading() {
|
||||
XCTAssertFalse(viewModel.state.isLoading)
|
||||
pollHistoryService.nextBatchPublishers = [MockPollPublisher.loadingPolls, MockPollPublisher.emptyPolls]
|
||||
viewModel.process(viewAction: .viewAppeared)
|
||||
XCTAssertTrue(viewModel.state.isLoading)
|
||||
viewModel.process(viewAction: .viewAppeared)
|
||||
XCTAssertFalse(viewModel.state.isLoading)
|
||||
}
|
||||
|
||||
func testUpdatesAreHandled() throws {
|
||||
let mockUpdates: PassthroughSubject<TimelinePollDetails, Never> = .init()
|
||||
pollHistoryService.updatesPublisher = mockUpdates.eraseToAnyPublisher()
|
||||
viewModel.process(viewAction: .viewAppeared)
|
||||
|
||||
var firstPoll = try XCTUnwrap(try polls.first)
|
||||
XCTAssertEqual(firstPoll.question, "Do you like the active poll number 1?")
|
||||
firstPoll.question = "foo"
|
||||
|
||||
mockUpdates.send(firstPoll)
|
||||
|
||||
let updatedPoll = try XCTUnwrap(viewModel.state.polls?.first)
|
||||
XCTAssertEqual(updatedPoll.question, "foo")
|
||||
}
|
||||
|
||||
func testSegmentsAreUpdated() throws {
|
||||
viewModel.process(viewAction: .viewAppeared)
|
||||
XCTAssertFalse(try polls.isEmpty)
|
||||
XCTAssertTrue(try polls.allSatisfy { !$0.closed })
|
||||
|
||||
viewModel.state.bindings.mode = .past
|
||||
viewModel.process(viewAction: .segmentDidChange)
|
||||
|
||||
XCTAssertTrue(try polls.allSatisfy(\.closed))
|
||||
}
|
||||
|
||||
func testPollsAreReverseOrdered() throws {
|
||||
viewModel.process(viewAction: .viewAppeared)
|
||||
|
||||
let pollDates = try polls.map(\.startDate)
|
||||
XCTAssertEqual(pollDates, pollDates.sorted(by: { $0 > $1 }))
|
||||
}
|
||||
|
||||
func testLivePollsAreHandled() throws {
|
||||
pollHistoryService.nextBatchPublishers = [MockPollPublisher.emptyPolls]
|
||||
pollHistoryService.livePollsPublisher = Just(mockPoll).eraseToAnyPublisher()
|
||||
viewModel.process(viewAction: .viewAppeared)
|
||||
XCTAssertEqual(viewModel.state.polls?.count, 1)
|
||||
XCTAssertEqual(viewModel.state.polls?.first?.id, "id")
|
||||
}
|
||||
|
||||
func testLivePollsDontChangeLoadingState() throws {
|
||||
let livePolls = PassthroughSubject<TimelinePollDetails, Never>()
|
||||
pollHistoryService.nextBatchPublishers = [MockPollPublisher.loadingPolls]
|
||||
pollHistoryService.livePollsPublisher = livePolls.eraseToAnyPublisher()
|
||||
viewModel.process(viewAction: .viewAppeared)
|
||||
XCTAssertTrue(viewModel.state.isLoading)
|
||||
XCTAssertNil(viewModel.state.polls)
|
||||
livePolls.send(mockPoll)
|
||||
XCTAssertTrue(viewModel.state.isLoading)
|
||||
XCTAssertNotNil(viewModel.state.polls)
|
||||
XCTAssertEqual(viewModel.state.polls?.count, 1)
|
||||
}
|
||||
|
||||
func testAfterFailureCompletionIsCalled() throws {
|
||||
pollHistoryService.nextBatchPublishers = [MockPollPublisher.failure]
|
||||
viewModel.process(viewAction: .viewAppeared)
|
||||
XCTAssertFalse(viewModel.state.isLoading)
|
||||
XCTAssertNotNil(viewModel.state.polls)
|
||||
XCTAssertNotNil(viewModel.state.bindings.alertInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private extension PollHistoryViewModelTests {
|
||||
var polls: [TimelinePollDetails] {
|
||||
get throws {
|
||||
try XCTUnwrap(viewModel.state.polls)
|
||||
}
|
||||
}
|
||||
|
||||
var mockPoll: TimelinePollDetails {
|
||||
.init(id: "id",
|
||||
question: "Do you like polls?",
|
||||
answerOptions: [],
|
||||
closed: false,
|
||||
startDate: .init(),
|
||||
totalAnswerCount: 3,
|
||||
type: .undisclosed,
|
||||
eventType: .started,
|
||||
maxAllowedSelections: 1,
|
||||
hasBeenEdited: false,
|
||||
hasDecryptionError: false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PollHistory: View {
|
||||
@Environment(\.theme) private var theme
|
||||
|
||||
@ObservedObject var viewModel: PollHistoryViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
SegmentedPicker(
|
||||
segments: PollHistoryMode.allCases,
|
||||
selection: $viewModel.mode,
|
||||
interSegmentSpacing: 14
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
content
|
||||
}
|
||||
.padding(.top, 32)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(theme.colors.background.ignoresSafeArea())
|
||||
.accentColor(theme.colors.accent)
|
||||
.navigationTitle(VectorL10n.pollHistoryTitle)
|
||||
.onAppear {
|
||||
viewModel.send(viewAction: .viewAppeared)
|
||||
}
|
||||
.onChange(of: viewModel.mode) { _ in
|
||||
viewModel.send(viewAction: .segmentDidChange)
|
||||
}
|
||||
.alert(item: $viewModel.alertInfo) {
|
||||
$0.alert
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if viewModel.viewState.polls == nil {
|
||||
loadingView
|
||||
} else if viewModel.viewState.polls?.isEmpty == true {
|
||||
noPollsView
|
||||
} else {
|
||||
pollListView
|
||||
}
|
||||
}
|
||||
|
||||
private var pollListView: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 32) {
|
||||
ForEach(viewModel.viewState.polls ?? []) { pollData in
|
||||
Button(action: {
|
||||
viewModel.send(viewAction: .showPollDetail(poll: pollData))
|
||||
}) {
|
||||
PollListItem(pollData: pollData)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
loadMoreButton
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.top, 32)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var loadMoreButton: some View {
|
||||
if viewModel.viewState.canLoadMoreContent {
|
||||
HStack(spacing: 8) {
|
||||
if viewModel.viewState.isLoading {
|
||||
spinner
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.send(viewAction: .loadMoreContent)
|
||||
} label: {
|
||||
Text(VectorL10n.pollHistoryLoadMore)
|
||||
.font(theme.fonts.body)
|
||||
}
|
||||
.accessibilityIdentifier("PollHistory.loadMore")
|
||||
.disabled(viewModel.viewState.isLoading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var spinner: some View {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
}
|
||||
|
||||
private var noPollsView: some View {
|
||||
VStack(spacing: 32) {
|
||||
Text(viewModel.emptyPollsText)
|
||||
.font(theme.fonts.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
.padding(.horizontal, 16)
|
||||
.accessibilityIdentifier("PollHistory.emptyText")
|
||||
|
||||
if viewModel.viewState.canLoadMoreContent {
|
||||
loadMoreButton
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
HStack(spacing: 8) {
|
||||
spinner
|
||||
|
||||
Text(VectorL10n.pollHistoryLoadingText)
|
||||
.font(theme.fonts.body)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
.frame(maxHeight: .infinity)
|
||||
.accessibilityIdentifier("PollHistory.loadingText")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
extension PollHistoryMode: CustomStringConvertible {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .active:
|
||||
return VectorL10n.pollHistoryActiveSegmentTitle
|
||||
case .past:
|
||||
return VectorL10n.pollHistoryPastSegmentTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct PollHistory_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockPollHistoryScreenState.stateRenderer
|
||||
|
||||
static var previews: some View {
|
||||
stateRenderer.screenGroup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PollListItem: View {
|
||||
@Environment(\.theme) private var theme
|
||||
|
||||
private let pollData: TimelinePollDetails
|
||||
@ScaledMetric private var imageSize = 16
|
||||
|
||||
init(pollData: TimelinePollDetails) {
|
||||
self.pollData = pollData
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(DateFormatter.pollShortDateFormatter.string(from: pollData.startDate))
|
||||
.foregroundColor(theme.colors.tertiaryContent)
|
||||
.font(theme.fonts.caption1)
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Image(uiImage: Asset.Images.pollHistory.image)
|
||||
.resizable()
|
||||
.frame(width: imageSize, height: imageSize)
|
||||
|
||||
Text(pollData.question)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.font(theme.fonts.body)
|
||||
.lineLimit(2)
|
||||
.accessibilityLabel("PollListItem.title")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if pollData.closed {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
let winningOptions = pollData.answerOptions.filter(\.winner)
|
||||
|
||||
ForEach(winningOptions) {
|
||||
TimelinePollAnswerOptionButton(poll: pollData, answerOption: $0, action: nil)
|
||||
}
|
||||
|
||||
resultView
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var resultView: some View {
|
||||
let text = pollData.totalAnswerCount == 1 ? VectorL10n.pollTimelineTotalFinalResultsOneVote : VectorL10n.pollTimelineTotalFinalResults(Int(pollData.totalAnswerCount))
|
||||
|
||||
return Text(text)
|
||||
.font(theme.fonts.footnote)
|
||||
.foregroundColor(theme.colors.tertiaryContent)
|
||||
}
|
||||
}
|
||||
|
||||
extension DateFormatter {
|
||||
static let pollShortDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .none
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeZone = .init(identifier: "UTC")
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct PollListItem_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
let pollData1 = TimelinePollDetails(id: UUID().uuidString,
|
||||
question: "Do you like polls?",
|
||||
answerOptions: [.init(id: "id", text: "Yes, of course!", count: 18, winner: true, selected: true)],
|
||||
closed: true,
|
||||
startDate: .init(),
|
||||
totalAnswerCount: 30,
|
||||
type: .disclosed,
|
||||
eventType: .started,
|
||||
maxAllowedSelections: 1,
|
||||
hasBeenEdited: false,
|
||||
hasDecryptionError: false)
|
||||
|
||||
let pollData2 = TimelinePollDetails(id: UUID().uuidString,
|
||||
question: "Do you like polls?",
|
||||
answerOptions: [.init(id: "id", text: "Yes, of course!", count: 18, winner: true, selected: true)],
|
||||
closed: false,
|
||||
startDate: .init(),
|
||||
totalAnswerCount: 30,
|
||||
type: .disclosed,
|
||||
eventType: .started,
|
||||
maxAllowedSelections: 1,
|
||||
hasBeenEdited: false,
|
||||
hasDecryptionError: false)
|
||||
|
||||
let pollData3 = TimelinePollDetails(id: UUID().uuidString,
|
||||
question: "Do you like polls?",
|
||||
answerOptions: [
|
||||
.init(id: "id1", text: "Yes, of course!", count: 15, winner: true, selected: true),
|
||||
.init(id: "id2", text: "No, I don't :-(", count: 15, winner: true, selected: true)
|
||||
],
|
||||
closed: true,
|
||||
startDate: .init(),
|
||||
totalAnswerCount: 30,
|
||||
type: .disclosed,
|
||||
eventType: .started,
|
||||
maxAllowedSelections: 1,
|
||||
hasBeenEdited: false,
|
||||
hasDecryptionError: false)
|
||||
|
||||
ForEach([pollData1, pollData2, pollData3]) { poll in
|
||||
PollListItem(pollData: poll)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SegmentedPicker<Segment: Hashable & CustomStringConvertible>: View {
|
||||
private let segments: [Segment]
|
||||
private let selection: Binding<Segment>
|
||||
private let interSegmentSpacing: CGFloat
|
||||
|
||||
@Environment(\.theme) private var theme
|
||||
|
||||
init(segments: [Segment], selection: Binding<Segment>, interSegmentSpacing: CGFloat) {
|
||||
self.segments = segments
|
||||
self.selection = selection
|
||||
self.interSegmentSpacing = interSegmentSpacing
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: interSegmentSpacing) {
|
||||
ForEach(segments, id: \.hashValue) { segment in
|
||||
let isSelectedSegment = segment == selection.wrappedValue
|
||||
|
||||
Button {
|
||||
selection.wrappedValue = segment
|
||||
} label: {
|
||||
Text(segment.description)
|
||||
.font(isSelectedSegment ? theme.fonts.headline : theme.fonts.body)
|
||||
.underlineBar(isSelectedSegment)
|
||||
}
|
||||
.accentColor(isSelectedSegment ? theme.colors.accent : theme.colors.primaryContent)
|
||||
.accessibilityLabel(segment.description)
|
||||
.accessibilityValue(isSelectedSegment ? VectorL10n.accessibilitySelected : "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Text {
|
||||
@ViewBuilder
|
||||
func underlineBar(_ isActive: Bool) -> some View {
|
||||
if #available(iOS 15.0, *) {
|
||||
overlay(alignment: .bottom) {
|
||||
if isActive {
|
||||
Rectangle()
|
||||
.frame(height: 1)
|
||||
.offset(y: 2)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
underline(isActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SegmentedPicker_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SegmentedPicker(
|
||||
segments: [
|
||||
"Segment 1",
|
||||
"Segment 2"
|
||||
],
|
||||
selection: .constant("Segment 1"),
|
||||
interSegmentSpacing: 14
|
||||
)
|
||||
|
||||
SegmentedPicker(
|
||||
segments: [
|
||||
"Segment 1",
|
||||
"Segment 2"
|
||||
],
|
||||
selection: .constant("Segment 2"),
|
||||
interSegmentSpacing: 14
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -51,7 +51,7 @@ enum MockRoomAccessTypeChooserScreenState: MockScreenState, CaseIterable {
|
||||
return (
|
||||
[service, viewModel],
|
||||
AnyView(RoomAccessTypeChooser(viewModel: viewModel.context, roomName: "Room Name")
|
||||
.addDependency(MockAvatarService.example))
|
||||
.environmentObject(AvatarViewModel.withMockedServices()))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ final class RoomUpgradeCoordinator: Coordinator, Presentable {
|
||||
self.parameters = parameters
|
||||
let viewModel = RoomUpgradeViewModel.makeRoomUpgradeViewModel(roomUpgradeService: RoomUpgradeService(session: parameters.session, roomId: parameters.roomId, parentSpaceId: parameters.parentSpaceId, versionOverride: parameters.versionOverride))
|
||||
let view = RoomUpgrade(viewModel: viewModel.context)
|
||||
.addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager))
|
||||
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.session.mediaManager)))
|
||||
roomUpgradeViewModel = viewModel
|
||||
roomUpgradeHostingController = VectorHostingController(rootView: view)
|
||||
roomUpgradeHostingController.view.backgroundColor = .clear
|
||||
|
||||
@@ -49,7 +49,7 @@ enum MockRoomUpgradeScreenState: MockScreenState, CaseIterable {
|
||||
return (
|
||||
[service, viewModel],
|
||||
AnyView(RoomUpgrade(viewModel: viewModel.context)
|
||||
.addDependency(MockAvatarService.example))
|
||||
.environmentObject(AvatarViewModel.withMockedServices()))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
|
||||
private let selectedAnswerIdentifiersSubject = PassthroughSubject<[String], Never>()
|
||||
|
||||
private var pollAggregator: PollAggregator
|
||||
private var viewModel: TimelinePollViewModelProtocol!
|
||||
private(set) var viewModel: TimelinePollViewModelProtocol!
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: Public
|
||||
@@ -86,6 +86,10 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
|
||||
func toPresentable() -> UIViewController {
|
||||
VectorHostingController(rootView: TimelinePollView(viewModel: viewModel.context))
|
||||
}
|
||||
|
||||
func toView() -> any View {
|
||||
TimelinePollView(viewModel: viewModel.context)
|
||||
}
|
||||
|
||||
func canEndPoll() -> Bool {
|
||||
pollAggregator.poll.isClosed == false
|
||||
@@ -114,10 +118,17 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
|
||||
func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { }
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
// PollProtocol is intentionally not available in the SwiftUI target as we don't want
|
||||
// to add the SDK as a dependency to it. We need to translate from one to the other on this level.
|
||||
|
||||
func buildTimelinePollFrom(_ poll: PollProtocol) -> TimelinePollDetails {
|
||||
let representedType: TimelinePollEventType = parameters.pollEvent.eventType == .pollStart ? .started : .ended
|
||||
return .init(poll: poll, represent: representedType)
|
||||
}
|
||||
}
|
||||
|
||||
// PollProtocol is intentionally not available in the SwiftUI target as we don't want
|
||||
// to add the SDK as a dependency to it. We need to translate from one to the other on this level.
|
||||
extension TimelinePollDetails {
|
||||
init(poll: PollProtocol, represent eventType: TimelinePollEventType) {
|
||||
let answerOptions = poll.answerOptions.map { pollAnswerOption in
|
||||
TimelinePollAnswerOption(id: pollAnswerOption.id,
|
||||
text: pollAnswerOption.text,
|
||||
@@ -126,21 +137,27 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
|
||||
selected: pollAnswerOption.isCurrentUserSelection)
|
||||
}
|
||||
|
||||
return TimelinePollDetails(question: poll.text,
|
||||
answerOptions: answerOptions,
|
||||
closed: poll.isClosed,
|
||||
totalAnswerCount: poll.totalAnswerCount,
|
||||
type: pollKindToTimelinePollType(poll.kind),
|
||||
eventType: parameters.pollEvent.eventType == .pollStart ? .started : .ended,
|
||||
maxAllowedSelections: poll.maxAllowedSelections,
|
||||
hasBeenEdited: poll.hasBeenEdited,
|
||||
hasDecryptionError: poll.hasDecryptionError)
|
||||
}
|
||||
|
||||
private func pollKindToTimelinePollType(_ kind: PollKind) -> TimelinePollType {
|
||||
let mapping = [PollKind.disclosed: TimelinePollType.disclosed,
|
||||
PollKind.undisclosed: TimelinePollType.undisclosed]
|
||||
|
||||
return mapping[kind] ?? .disclosed
|
||||
self.init(id: poll.id,
|
||||
question: poll.text,
|
||||
answerOptions: answerOptions,
|
||||
closed: poll.isClosed,
|
||||
startDate: poll.startDate,
|
||||
totalAnswerCount: poll.totalAnswerCount,
|
||||
type: poll.kind.timelinePollType,
|
||||
eventType: eventType,
|
||||
maxAllowedSelections: poll.maxAllowedSelections,
|
||||
hasBeenEdited: poll.hasBeenEdited,
|
||||
hasDecryptionError: poll.hasDecryptionError)
|
||||
}
|
||||
}
|
||||
|
||||
private extension PollKind {
|
||||
var timelinePollType: TimelinePollType {
|
||||
switch self {
|
||||
case .disclosed:
|
||||
return .disclosed
|
||||
case .undisclosed:
|
||||
return .undisclosed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,11 @@ class TimelinePollViewModelTests: XCTestCase {
|
||||
TimelinePollAnswerOption(id: "2", text: "2", count: 1, winner: false, selected: false),
|
||||
TimelinePollAnswerOption(id: "3", text: "3", count: 1, winner: false, selected: false)]
|
||||
|
||||
let timelinePoll = TimelinePollDetails(question: "Question",
|
||||
let timelinePoll = TimelinePollDetails(id: "poll-id",
|
||||
question: "Question",
|
||||
answerOptions: answerOptions,
|
||||
closed: false,
|
||||
startDate: .init(),
|
||||
totalAnswerCount: 3,
|
||||
type: .disclosed,
|
||||
eventType: .started,
|
||||
|
||||
@@ -62,37 +62,20 @@ extension MutableCollection where Element == TimelinePollAnswerOption {
|
||||
}
|
||||
|
||||
struct TimelinePollDetails {
|
||||
var id: String
|
||||
var question: String
|
||||
var answerOptions: [TimelinePollAnswerOption]
|
||||
var closed: Bool
|
||||
var startDate: Date
|
||||
var totalAnswerCount: UInt
|
||||
var type: TimelinePollType
|
||||
var eventType: TimelinePollEventType
|
||||
var maxAllowedSelections: UInt
|
||||
var hasBeenEdited = true
|
||||
var hasBeenEdited: Bool
|
||||
var hasDecryptionError: Bool
|
||||
|
||||
init(question: String, answerOptions: [TimelinePollAnswerOption],
|
||||
closed: Bool,
|
||||
totalAnswerCount: UInt,
|
||||
type: TimelinePollType,
|
||||
eventType: TimelinePollEventType,
|
||||
maxAllowedSelections: UInt,
|
||||
hasBeenEdited: Bool,
|
||||
hasDecryptionError: Bool) {
|
||||
self.question = question
|
||||
self.answerOptions = answerOptions
|
||||
self.closed = closed
|
||||
self.totalAnswerCount = totalAnswerCount
|
||||
self.type = type
|
||||
self.eventType = eventType
|
||||
self.maxAllowedSelections = maxAllowedSelections
|
||||
self.hasBeenEdited = hasBeenEdited
|
||||
self.hasDecryptionError = hasDecryptionError
|
||||
}
|
||||
|
||||
var hasCurrentUserVoted: Bool {
|
||||
answerOptions.filter { $0.selected == true }.count > 0
|
||||
answerOptions.contains(where: \.selected)
|
||||
}
|
||||
|
||||
var shouldDiscloseResults: Bool {
|
||||
@@ -108,6 +91,8 @@ struct TimelinePollDetails {
|
||||
}
|
||||
}
|
||||
|
||||
extension TimelinePollDetails: Identifiable { }
|
||||
|
||||
struct TimelinePollViewState: BindableState {
|
||||
var poll: TimelinePollDetails
|
||||
var bindings: TimelinePollViewStateBindings
|
||||
|
||||
@@ -33,9 +33,11 @@ enum MockTimelinePollScreenState: MockScreenState, CaseIterable {
|
||||
TimelinePollAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true),
|
||||
TimelinePollAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false)]
|
||||
|
||||
let poll = TimelinePollDetails(question: "Question",
|
||||
let poll = TimelinePollDetails(id: "id",
|
||||
question: "Question",
|
||||
answerOptions: answerOptions,
|
||||
closed: self == .closedDisclosed || self == .closedUndisclosed ? true : false,
|
||||
startDate: .init(),
|
||||
totalAnswerCount: 20,
|
||||
type: self == .closedDisclosed || self == .openDisclosed ? .disclosed : .undisclosed,
|
||||
eventType: self == .closedPollEnded ? .ended : .started,
|
||||
|
||||
@@ -25,23 +25,26 @@ struct TimelinePollAnswerOptionButton: View {
|
||||
|
||||
let poll: TimelinePollDetails
|
||||
let answerOption: TimelinePollAnswerOption
|
||||
let action: () -> Void
|
||||
let action: (() -> Void)?
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Button {
|
||||
action?()
|
||||
} label: {
|
||||
let rect = RoundedRectangle(cornerRadius: 4.0)
|
||||
answerOptionLabel
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 8.0)
|
||||
.padding(.top, 12.0)
|
||||
.padding(.bottom, 12.0)
|
||||
.padding(.bottom, 8.0)
|
||||
.clipShape(rect)
|
||||
.overlay(rect.stroke(borderAccentColor, lineWidth: 1.0))
|
||||
.accentColor(progressViewAccentColor)
|
||||
}
|
||||
.accessibilityIdentifier("PollAnswerOption\(optionIndex)")
|
||||
.disabled(action == nil)
|
||||
}
|
||||
|
||||
var answerOptionLabel: some View {
|
||||
@@ -55,20 +58,12 @@ struct TimelinePollAnswerOptionButton: View {
|
||||
.font(theme.fonts.body)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.accessibilityIdentifier("PollAnswerOption\(optionIndex)Label")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if poll.closed, answerOption.winner {
|
||||
Spacer()
|
||||
Image(uiImage: Asset.Images.pollWinnerIcon.image)
|
||||
}
|
||||
}
|
||||
|
||||
if poll.type == .disclosed || poll.closed {
|
||||
HStack {
|
||||
ProgressView(value: Double(poll.shouldDiscloseResults ? answerOption.count : 0),
|
||||
total: Double(poll.totalAnswerCount))
|
||||
.progressViewStyle(LinearProgressViewStyle())
|
||||
.scaleEffect(x: 1.0, y: 1.2, anchor: .center)
|
||||
.accessibilityIdentifier("PollAnswerOption\(optionIndex)Progress")
|
||||
HStack(spacing: 6) {
|
||||
if poll.closed, answerOption.winner {
|
||||
Image(uiImage: Asset.Images.pollWinnerIcon.image)
|
||||
}
|
||||
|
||||
if poll.shouldDiscloseResults {
|
||||
Text(answerOption.count == 1 ? VectorL10n.pollTimelineOneVote : VectorL10n.pollTimelineVotesCount(Int(answerOption.count)))
|
||||
@@ -78,6 +73,13 @@ struct TimelinePollAnswerOptionButton: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if poll.type == .disclosed || poll.closed {
|
||||
ProgressView(value: Double(poll.shouldDiscloseResults ? answerOption.count : 0), total: Double(poll.totalAnswerCount))
|
||||
.progressViewStyle(LinearProgressViewStyle.linear)
|
||||
.scaleEffect(x: 1.0, y: 1.2, anchor: .center)
|
||||
.accessibilityIdentifier("PollAnswerOption\(optionIndex)Progress")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,12 +145,15 @@ struct TimelinePollAnswerOptionButton_Previews: PreviewProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
static func buildPoll(closed: Bool, type: TimelinePollType) -> TimelinePollDetails {
|
||||
TimelinePollDetails(question: "",
|
||||
TimelinePollDetails(id: UUID().uuidString,
|
||||
question: "",
|
||||
answerOptions: [],
|
||||
closed: closed,
|
||||
startDate: .init(),
|
||||
totalAnswerCount: 100,
|
||||
type: type,
|
||||
eventType: .started,
|
||||
|
||||
@@ -61,7 +61,7 @@ final class UserSuggestionCoordinator: Coordinator, Presentable {
|
||||
|
||||
let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService)
|
||||
let view = UserSuggestionList(viewModel: viewModel.context)
|
||||
.addDependency(AvatarService.instantiate(mediaManager: parameters.mediaManager))
|
||||
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager)))
|
||||
|
||||
userSuggestionViewModel = viewModel
|
||||
userSuggestionHostingController = VectorHostingController(rootView: view)
|
||||
@@ -105,7 +105,7 @@ final class UserSuggestionCoordinator: Coordinator, Presentable {
|
||||
private func calculateViewHeight() -> CGFloat {
|
||||
let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService)
|
||||
let view = UserSuggestionList(viewModel: viewModel.context)
|
||||
.addDependency(AvatarService.instantiate(mediaManager: parameters.mediaManager))
|
||||
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager)))
|
||||
|
||||
let controller = VectorHostingController(rootView: view)
|
||||
guard let view = controller.view else {
|
||||
|
||||
@@ -37,7 +37,7 @@ enum MockUserSuggestionScreenState: MockScreenState, CaseIterable {
|
||||
return (
|
||||
[service, listViewModel],
|
||||
AnyView(UserSuggestionListWithInput(viewModel: viewModel)
|
||||
.addDependency(MockAvatarService.example))
|
||||
.environmentObject(AvatarViewModel.withMockedServices()))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,6 @@ struct UserSuggestionListItem: View {
|
||||
struct UserSuggestionHeader_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
UserSuggestionListItem(avatar: MockAvatarInput.example, displayName: "Alice", userId: "@alice:matrix.org")
|
||||
.addDependency(MockAvatarService.example)
|
||||
.environmentObject(AvatarViewModel.withMockedServices())
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -57,7 +57,8 @@ final class VoiceBroadcastPlaybackCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
|
||||
deinit {
|
||||
viewModel.context.send(viewAction: .redact)
|
||||
// If init has failed, our viewmodel will be nil.
|
||||
viewModel?.context.send(viewAction: .redact)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -66,7 +67,7 @@ final class VoiceBroadcastPlaybackCoordinator: Coordinator, Presentable {
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
let view = VoiceBroadcastPlaybackView(viewModel: viewModel.context)
|
||||
.addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager))
|
||||
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.session.mediaManager)))
|
||||
return VectorHostingController(rootView: view)
|
||||
}
|
||||
|
||||
@@ -80,8 +81,15 @@ final class VoiceBroadcastPlaybackCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
|
||||
func endVoiceBroadcast() {}
|
||||
|
||||
|
||||
func pausePlaying() {
|
||||
viewModel.context.send(viewAction: .pause)
|
||||
}
|
||||
|
||||
func pausePlayingInProgressVoiceBroadcast() {
|
||||
// Pause the playback if we are playing a live voice broadcast (or waiting for more chunks)
|
||||
if [.playing, .buffering].contains(viewModel.context.viewState.playbackState), viewModel.context.viewState.broadcastState != .stopped {
|
||||
viewModel.context.send(viewAction: .pause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -78,6 +78,12 @@ import Foundation
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func pausePlayingInProgressVoiceBroadcast() {
|
||||
coordinatorsForEventIdentifiers.forEach { _, coordinator in
|
||||
coordinator.pausePlayingInProgressVoiceBroadcast()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEvent(event: MXEvent, direction: MXTimelineDirection, customObject: Any?) {
|
||||
if direction == .backwards {
|
||||
// ignore backwards events
|
||||
|
||||
+180
-26
@@ -16,6 +16,7 @@
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import MediaPlayer
|
||||
|
||||
// TODO: VoiceBroadcastPlaybackViewModel must be revisited in order to not depend on MatrixSDK
|
||||
// We need a VoiceBroadcastPlaybackServiceProtocol and VoiceBroadcastAggregatorProtocol
|
||||
@@ -43,7 +44,23 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
private var reloadVoiceBroadcastChunkQueue: Bool = false
|
||||
private var seekToChunkTime: TimeInterval?
|
||||
|
||||
/// The last chunk we tried to load
|
||||
private var lastChunkProcessed: UInt = 0
|
||||
/// The last chunk correctly loaded and added to the player's queue
|
||||
private var lastChunkAddedToPlayer: UInt = 0
|
||||
|
||||
private var hasAttachmentErrors: Bool = false {
|
||||
didSet {
|
||||
updateErrorState()
|
||||
}
|
||||
}
|
||||
|
||||
private var isPlayingLastChunk: Bool {
|
||||
// We can't play the last chunk if the brodcast is not stopped
|
||||
guard state.broadcastState == .stopped else {
|
||||
return false
|
||||
}
|
||||
|
||||
let chunks = reorderVoiceBroadcastChunks(chunks: Array(voiceBroadcastAggregator.voiceBroadcast.chunks))
|
||||
guard let chunkDuration = chunks.last?.duration else {
|
||||
return false
|
||||
@@ -52,6 +69,21 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
return state.bindings.progress + 1000 >= state.playingState.duration - Float(chunkDuration)
|
||||
}
|
||||
|
||||
/// Current chunk loaded in the audio player
|
||||
private var currentChunk: VoiceBroadcastChunk? {
|
||||
guard let currentAudioPlayerUrl = audioPlayer?.currentUrl,
|
||||
let currentEventId = voiceBroadcastAttachmentCacheManagerLoadResults.first(where: { result in
|
||||
result.url == currentAudioPlayerUrl
|
||||
})?.eventIdentifier else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let currentChunk = voiceBroadcastAggregator.voiceBroadcast.chunks.first(where: { chunk in
|
||||
chunk.attachment.eventId == currentEventId
|
||||
})
|
||||
return currentChunk
|
||||
}
|
||||
|
||||
private var isLivePlayback: Bool {
|
||||
return (!isPlaybackInitialized || isPlayingLastChunk) && (state.broadcastState == .started || state.broadcastState == .resumed)
|
||||
}
|
||||
@@ -89,7 +121,9 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
broadcastState: voiceBroadcastAggregator.voiceBroadcastState,
|
||||
playbackState: .stopped,
|
||||
playingState: VoiceBroadcastPlayingState(duration: Float(voiceBroadcastAggregator.voiceBroadcast.duration), isLive: false, canMoveForward: false, canMoveBackward: false),
|
||||
bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0))
|
||||
bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0),
|
||||
decryptionState: VoiceBroadcastPlaybackDecryptionState(errorCount: 0),
|
||||
showPlaybackError: false)
|
||||
super.init(initialViewState: viewState)
|
||||
|
||||
displayLink = CADisplayLink(target: WeakTarget(self, selector: #selector(handleDisplayLinkTick)), selector: WeakTarget.triggerSelector)
|
||||
@@ -168,11 +202,24 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
private func stopIfVoiceBroadcastOver() {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] stopIfVoiceBroadcastOver")
|
||||
|
||||
var shouldStop = false
|
||||
|
||||
// Check if the broadcast is over before stopping everything
|
||||
// If not, the player should not stopped. The view state must be move to buffering
|
||||
if state.broadcastState == .stopped, isPlayingLastChunk {
|
||||
if state.broadcastState == .stopped {
|
||||
// If we known the last chunk sequence, use it to check if we need to stop
|
||||
// Note: it's possible to be in .stopped state and to still have a last chunk sequence at 0 (old versions or a crash during recording). In this case, we use isPlayingLastChunk as a fallback solution
|
||||
if voiceBroadcastAggregator.voiceBroadcastLastChunkSequence > 0 {
|
||||
// we should stop only if we have already processed the last chunk
|
||||
shouldStop = (lastChunkProcessed == voiceBroadcastAggregator.voiceBroadcastLastChunkSequence)
|
||||
} else {
|
||||
shouldStop = isPlayingLastChunk
|
||||
}
|
||||
}
|
||||
|
||||
if shouldStop {
|
||||
stop()
|
||||
} else {
|
||||
// If not, the player should not stopped. The view state must be move to buffering
|
||||
state.playbackState = .buffering
|
||||
}
|
||||
}
|
||||
@@ -200,9 +247,12 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
|
||||
private func seek(to seekTime: Float) {
|
||||
// Flush the chunks queue and the current audio player playlist
|
||||
lastChunkProcessed = 0
|
||||
lastChunkAddedToPlayer = 0
|
||||
voiceBroadcastChunkQueue = []
|
||||
reloadVoiceBroadcastChunkQueue = isProcessingVoiceBroadcastChunk
|
||||
audioPlayer?.removeAllPlayerItems()
|
||||
hasAttachmentErrors = false
|
||||
|
||||
let chunks = reorderVoiceBroadcastChunks(chunks: Array(voiceBroadcastAggregator.voiceBroadcast.chunks))
|
||||
|
||||
@@ -289,12 +339,14 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
return
|
||||
}
|
||||
|
||||
self.lastChunkProcessed = chunk.sequence
|
||||
|
||||
switch result {
|
||||
case .success(let result):
|
||||
guard result.eventIdentifier == chunk.attachment.eventId else {
|
||||
return
|
||||
}
|
||||
|
||||
self.lastChunkAddedToPlayer = max(self.lastChunkAddedToPlayer, chunk.sequence)
|
||||
self.voiceBroadcastAttachmentCacheManagerLoadResults.append(result)
|
||||
|
||||
// Instanciate audioPlayer if needed.
|
||||
@@ -302,6 +354,7 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
// Init and start the player on the first chunk
|
||||
let audioPlayer = self.mediaServiceProvider.audioPlayerForIdentifier(result.eventIdentifier)
|
||||
audioPlayer.registerDelegate(self)
|
||||
self.mediaServiceProvider.registerNowPlayingInfoDelegate(self, forPlayer: audioPlayer)
|
||||
|
||||
audioPlayer.loadContentFromURL(result.url, displayName: chunk.attachment.originalFileName)
|
||||
self.audioPlayer = audioPlayer
|
||||
@@ -331,19 +384,46 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
audioPlayer.seekToTime(time)
|
||||
self.seekToChunkTime = nil
|
||||
}
|
||||
|
||||
|
||||
self.hasAttachmentErrors = false
|
||||
self.processNextVoiceBroadcastChunk()
|
||||
|
||||
case .failure (let error):
|
||||
MXLog.error("[VoiceBroadcastPlaybackViewModel] processVoiceBroadcastChunkQueue: loadAttachment error", context: error)
|
||||
if self.voiceBroadcastChunkQueue.count == 0 {
|
||||
// No more chunk to try. Go to error
|
||||
self.state.playbackState = .error
|
||||
MXLog.error("[VoiceBroadcastPlaybackViewModel] processVoiceBroadcastChunkQueue: loadAttachment error", context: ["error": error, "chunk": chunk.sequence])
|
||||
self.hasAttachmentErrors = true
|
||||
// If nothing has been added to the player's queue, exit the buffer state
|
||||
if self.lastChunkAddedToPlayer == 0 {
|
||||
self.pause()
|
||||
}
|
||||
}
|
||||
|
||||
self.processNextVoiceBroadcastChunk()
|
||||
}
|
||||
}
|
||||
|
||||
private func resetErrorState() {
|
||||
state.showPlaybackError = false
|
||||
}
|
||||
|
||||
private func updateErrorState() {
|
||||
// Show an error if the playback state is .error
|
||||
var showPlaybackError = state.playbackState == .error
|
||||
|
||||
// Or if there is an attachment error
|
||||
if hasAttachmentErrors {
|
||||
// only if the audio player is not playing and has nothing left to play
|
||||
let audioPlayerIsPlaying = audioPlayer?.isPlaying ?? false
|
||||
let currentPlayerTime = audioPlayer?.currentTime ?? 0
|
||||
let currentPlayerDuration = audioPlayer?.duration ?? 0
|
||||
let currentChunkSequence = currentChunk?.sequence ?? 0
|
||||
let hasNoMoreChunkToPlay = (currentChunk == nil && lastChunkAddedToPlayer == 0) || (currentChunkSequence == lastChunkAddedToPlayer)
|
||||
if !audioPlayerIsPlaying && hasNoMoreChunkToPlay && (currentPlayerDuration - currentPlayerTime < 0.2) {
|
||||
showPlaybackError = true
|
||||
}
|
||||
}
|
||||
|
||||
state.showPlaybackError = showPlaybackError
|
||||
|
||||
}
|
||||
|
||||
private func updateDuration() {
|
||||
let duration = voiceBroadcastAggregator.voiceBroadcast.duration
|
||||
state.playingState.duration = Float(duration)
|
||||
@@ -366,23 +446,23 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
} else {
|
||||
seek(to: state.bindings.progress)
|
||||
}
|
||||
resetErrorState()
|
||||
}
|
||||
|
||||
@objc private func handleDisplayLinkTick() {
|
||||
guard let playingEventId = voiceBroadcastAttachmentCacheManagerLoadResults.first(where: { result in
|
||||
result.url == audioPlayer?.currentUrl
|
||||
})?.eventIdentifier,
|
||||
let playingSequence = voiceBroadcastAggregator.voiceBroadcast.chunks.first(where: { chunk in
|
||||
chunk.attachment.eventId == playingEventId
|
||||
})?.sequence else {
|
||||
guard let playingSequence = self.currentChunk?.sequence else {
|
||||
return
|
||||
}
|
||||
|
||||
let progress = Double(voiceBroadcastAggregator.voiceBroadcast.chunks.filter { chunk in
|
||||
chunk.sequence < playingSequence
|
||||
}.reduce(0) { $0 + $1.duration}) + (audioPlayer?.currentTime.rounded() ?? 0) * 1000
|
||||
|
||||
state.bindings.progress = Float(progress)
|
||||
|
||||
// Get the audioPlayer current time, which is the elapsed time in the currently playing media item.
|
||||
// Note: if the audioPlayer is not ready (eg. after a seek), its currentTime will be 0 and we shouldn't update the progress to avoid visual glitches.
|
||||
let currentTime = audioPlayer?.currentTime ?? .zero
|
||||
if currentTime > 0 {
|
||||
let progress = Double(voiceBroadcastAggregator.voiceBroadcast.chunks.filter { chunk in
|
||||
chunk.sequence < playingSequence
|
||||
}.reduce(0) { $0 + $1.duration}) + currentTime * 1000
|
||||
state.bindings.progress = Float(progress)
|
||||
}
|
||||
|
||||
updateUI()
|
||||
}
|
||||
@@ -401,7 +481,7 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
state.playingState.remainingTimeLabel = label
|
||||
|
||||
state.playingState.canMoveBackward = state.bindings.progress > 0
|
||||
state.playingState.canMoveForward = state.bindings.progress < state.playingState.duration
|
||||
state.playingState.canMoveForward = (state.playingState.duration - state.bindings.progress) > 500
|
||||
}
|
||||
|
||||
private func handleVoiceBroadcastChunksProcessing() {
|
||||
@@ -436,49 +516,123 @@ extension VoiceBroadcastPlaybackViewModel: VoiceBroadcastAggregatorDelegate {
|
||||
|
||||
// Handle the live icon appearance
|
||||
state.playingState.isLive = isLivePlayback
|
||||
|
||||
// Handle the case where the playback state is .buffering and the new broadcast state is .stopped
|
||||
if didReceiveState == .stopped, self.state.playbackState == .buffering {
|
||||
stopIfVoiceBroadcastOver()
|
||||
}
|
||||
}
|
||||
|
||||
func voiceBroadcastAggregatorDidUpdateData(_ aggregator: VoiceBroadcastAggregator) {
|
||||
|
||||
updateDuration()
|
||||
|
||||
if state.playbackState != .stopped, !isActuallyPaused {
|
||||
handleVoiceBroadcastChunksProcessing()
|
||||
}
|
||||
}
|
||||
|
||||
func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didUpdateUndecryptableEventList events: Set<MXEvent>) {
|
||||
state.decryptionState.errorCount = events.count
|
||||
if events.count > 0 {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] voice broadcast decryption error count: \(events.count)/\(aggregator.voiceBroadcast.chunks.count)")
|
||||
|
||||
if [.playing, .buffering].contains(state.playbackState) {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - VoiceMessageAudioPlayerDelegate
|
||||
extension VoiceBroadcastPlaybackViewModel: VoiceMessageAudioPlayerDelegate {
|
||||
func audioPlayerDidFinishLoading(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
updateErrorState()
|
||||
}
|
||||
|
||||
func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
state.playbackState = .playing
|
||||
state.playingState.isLive = isLivePlayback
|
||||
isPlaybackInitialized = true
|
||||
displayLink.isPaused = false
|
||||
resetErrorState()
|
||||
}
|
||||
|
||||
func audioPlayerDidPausePlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
state.playbackState = .paused
|
||||
state.playingState.isLive = false
|
||||
displayLink.isPaused = true
|
||||
}
|
||||
|
||||
func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] audioPlayerDidStopPlaying")
|
||||
state.playbackState = .stopped
|
||||
|
||||
updateErrorState()
|
||||
|
||||
state.playingState.isLive = false
|
||||
audioPlayer.deregisterDelegate(self)
|
||||
self.mediaServiceProvider.deregisterNowPlayingInfoDelegate(forPlayer: audioPlayer)
|
||||
self.audioPlayer = nil
|
||||
displayLink.isPaused = true
|
||||
}
|
||||
|
||||
func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError error: Error) {
|
||||
state.playbackState = .error
|
||||
updateErrorState()
|
||||
}
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] audioPlayerDidFinishPlaying: \(audioPlayer.playerItems.count)")
|
||||
stopIfVoiceBroadcastOver()
|
||||
if hasAttachmentErrors {
|
||||
stop()
|
||||
} else {
|
||||
stopIfVoiceBroadcastOver()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VoiceMessageNowPlayingInfoDelegate
|
||||
|
||||
extension VoiceBroadcastPlaybackViewModel: VoiceMessageNowPlayingInfoDelegate {
|
||||
|
||||
func shouldSetupRemoteCommandCenter(audioPlayer player: VoiceMessageAudioPlayer) -> Bool {
|
||||
guard BuildSettings.allowBackgroundAudioMessagePlayback, audioPlayer != nil, audioPlayer === player else {
|
||||
return false
|
||||
}
|
||||
|
||||
// we should setup the remote command center only for ended voice broadcast because we won't get new chunk if the app is in background.
|
||||
return state.broadcastState == .stopped
|
||||
}
|
||||
|
||||
func shouldDisconnectFromNowPlayingInfoCenter(audioPlayer player: VoiceMessageAudioPlayer) -> Bool {
|
||||
guard BuildSettings.allowBackgroundAudioMessagePlayback, audioPlayer != nil, audioPlayer === player else {
|
||||
return true
|
||||
}
|
||||
|
||||
// we should disconnect from the now playing info center if the playback is stopped or if the broadcast is in progress
|
||||
return state.playbackState == .stopped || state.broadcastState != .stopped
|
||||
}
|
||||
|
||||
func updateNowPlayingInfoCenter(forPlayer player: VoiceMessageAudioPlayer) {
|
||||
guard audioPlayer != nil, audioPlayer === player else {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't update the NowPlayingInfoCenter for live broadcasts
|
||||
guard state.broadcastState == .stopped else {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
return
|
||||
}
|
||||
|
||||
let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default()
|
||||
nowPlayingInfoCenter.nowPlayingInfo = [
|
||||
// Title
|
||||
MPMediaItemPropertyTitle: VectorL10n.voiceBroadcastPlaybackLockScreenPlaceholder,
|
||||
// Duration
|
||||
MPMediaItemPropertyPlaybackDuration: (state.playingState.duration / 1000.0) as Any,
|
||||
// Elapsed time
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: (state.bindings.progress / 1000.0) as Any,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceBroadcastPlaybackDecryptionErrorView: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
HStack(spacing: 0) {
|
||||
Image(uiImage: Asset.Images.errorIcon.image)
|
||||
.frame(width: 40, height: 40)
|
||||
Text(VectorL10n.voiceBroadcastPlaybackUnableToDecrypt)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(theme.fonts.caption1)
|
||||
.foregroundColor(theme.colors.alert)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceBroadcastPlaybackDecryptionErrorView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VoiceBroadcastPlaybackDecryptionErrorView()
|
||||
}
|
||||
}
|
||||
+3
-5
@@ -28,19 +28,17 @@ struct VoiceBroadcastPlaybackErrorView: View {
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
VStack {
|
||||
ZStack {
|
||||
HStack {
|
||||
Image(uiImage: Asset.Images.errorIcon.image)
|
||||
.frame(width: 40, height: 40)
|
||||
Text(VectorL10n.voiceBroadcastPlaybackLoadingError)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(theme.fonts.caption1)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.foregroundColor(theme.colors.alert)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(theme.colors.system.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-4
@@ -91,7 +91,7 @@ struct VoiceBroadcastPlaybackView: View {
|
||||
}
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
|
||||
if viewModel.viewState.broadcastState != .stopped {
|
||||
Label {
|
||||
Text(VectorL10n.voiceBroadcastLive)
|
||||
@@ -109,7 +109,12 @@ struct VoiceBroadcastPlaybackView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(EdgeInsets(top: 0.0, leading: 0.0, bottom: 4.0, trailing: 0.0))
|
||||
|
||||
if viewModel.viewState.playbackState == .error {
|
||||
if viewModel.viewState.decryptionState.errorCount > 0 {
|
||||
VoiceBroadcastPlaybackDecryptionErrorView()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.accessibilityIdentifier("decryptionErrorView")
|
||||
}
|
||||
else if viewModel.viewState.showPlaybackError {
|
||||
VoiceBroadcastPlaybackErrorView()
|
||||
} else {
|
||||
HStack (spacing: 34.0) {
|
||||
@@ -156,8 +161,8 @@ struct VoiceBroadcastPlaybackView: View {
|
||||
}
|
||||
|
||||
VoiceBroadcastSlider(value: $viewModel.progress,
|
||||
minValue: 0.0,
|
||||
maxValue: viewModel.viewState.playingState.duration) { didChange in
|
||||
minValue: 0.0,
|
||||
maxValue: viewModel.viewState.playingState.duration) { didChange in
|
||||
viewModel.send(viewAction: .sliderChange(didChange: didChange))
|
||||
}
|
||||
|
||||
|
||||
@@ -48,12 +48,18 @@ struct VoiceBroadcastPlayingState {
|
||||
var canMoveBackward: Bool
|
||||
}
|
||||
|
||||
struct VoiceBroadcastPlaybackDecryptionState {
|
||||
var errorCount: Int
|
||||
}
|
||||
|
||||
struct VoiceBroadcastPlaybackViewState: BindableState {
|
||||
var details: VoiceBroadcastPlaybackDetails
|
||||
var broadcastState: VoiceBroadcastInfoState
|
||||
var playbackState: VoiceBroadcastPlaybackState
|
||||
var playingState: VoiceBroadcastPlayingState
|
||||
var bindings: VoiceBroadcastPlaybackViewStateBindings
|
||||
var decryptionState: VoiceBroadcastPlaybackDecryptionState
|
||||
var showPlaybackError: Bool
|
||||
}
|
||||
|
||||
struct VoiceBroadcastPlaybackViewStateBindings {
|
||||
|
||||
+3
-2
@@ -43,11 +43,12 @@ enum MockVoiceBroadcastPlaybackScreenState: MockScreenState, CaseIterable {
|
||||
var screenView: ([Any], AnyView) {
|
||||
|
||||
let details = VoiceBroadcastPlaybackDetails(senderDisplayName: "Alice", avatarData: AvatarInput(mxContentUri: "", matrixItemId: "!fakeroomid:matrix.org", displayName: "The name of the room"))
|
||||
let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .started, playbackState: .stopped, playingState: VoiceBroadcastPlayingState(duration: 10.0, isLive: true, canMoveForward: false, canMoveBackward: false), bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0)))
|
||||
let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .started, playbackState: .stopped, playingState: VoiceBroadcastPlayingState(duration: 10.0, isLive: true, canMoveForward: false, canMoveBackward: false), bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0), decryptionState: VoiceBroadcastPlaybackDecryptionState(errorCount: 0), showPlaybackError: false))
|
||||
|
||||
return (
|
||||
[false, viewModel],
|
||||
AnyView(VoiceBroadcastPlaybackView(viewModel: viewModel.context))
|
||||
AnyView(VoiceBroadcastPlaybackView(viewModel: viewModel.context)
|
||||
.environmentObject(AvatarViewModel.withMockedServices()))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -61,13 +61,22 @@ final class VoiceBroadcastRecorderCoordinator: Coordinator, Presentable {
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
let view = VoiceBroadcastRecorderView(viewModel: voiceBroadcastRecorderViewModel.context)
|
||||
.addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager))
|
||||
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.session.mediaManager)))
|
||||
|
||||
return VectorHostingController(rootView: view)
|
||||
}
|
||||
|
||||
func pauseRecording() {
|
||||
voiceBroadcastRecorderViewModel.context.send(viewAction: .pause)
|
||||
}
|
||||
|
||||
func pauseRecordingOnError() {
|
||||
voiceBroadcastRecorderViewModel.context.send(viewAction: .pauseOnError)
|
||||
}
|
||||
|
||||
func isVoiceBroadcastRecording() -> Bool {
|
||||
return voiceBroadcastRecorderService.isRecording
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
}
|
||||
|
||||
+66
-8
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -32,13 +32,16 @@ import Foundation
|
||||
coordinatorsForEventIdentifiers.removeAll()
|
||||
}
|
||||
}
|
||||
didSet {
|
||||
sessionState = session?.state
|
||||
}
|
||||
}
|
||||
private var coordinatorsForEventIdentifiers = [String: VoiceBroadcastRecorderCoordinator]() {
|
||||
didSet {
|
||||
if !self.coordinatorsForEventIdentifiers.isEmpty && self.redactionsListener == nil {
|
||||
redactionsListener = session?.listenToEvents([MXEventType(identifier: kMXEventTypeStringRoomRedaction)], self.handleRedactedEvent)
|
||||
}
|
||||
|
||||
|
||||
if self.coordinatorsForEventIdentifiers.isEmpty && self.redactionsListener != nil {
|
||||
session?.removeListener(self.redactionsListener)
|
||||
self.redactionsListener = nil
|
||||
@@ -49,9 +52,19 @@ import Foundation
|
||||
|
||||
// MARK: Private
|
||||
private var currentEventIdentifier: String?
|
||||
|
||||
private var sessionState: MXSessionState?
|
||||
|
||||
private var sessionStateDidChangeObserver: Any?
|
||||
|
||||
// MARK: - Setup
|
||||
private override init() { }
|
||||
private override init() {
|
||||
super.init()
|
||||
self.registerNotificationObservers()
|
||||
}
|
||||
|
||||
deinit {
|
||||
unregisterNotificationObservers()
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
@@ -85,6 +98,19 @@ import Foundation
|
||||
voiceBroadcastRecorderCoordinatorForCurrentEvent()?.pauseRecording()
|
||||
}
|
||||
|
||||
/// Pause current voice broadcast recording without sending pending events.
|
||||
@objc public func pauseRecordingOnError() {
|
||||
voiceBroadcastRecorderCoordinatorForCurrentEvent()?.pauseRecordingOnError()
|
||||
}
|
||||
|
||||
@objc public func isVoiceBroadcastRecording() -> Bool {
|
||||
guard let coordinator = voiceBroadcastRecorderCoordinatorForCurrentEvent() else {
|
||||
return false
|
||||
}
|
||||
|
||||
return coordinator.isVoiceBroadcastRecording()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Retrieve the voiceBroadcast recorder coordinator for the current event or nil if it hasn't been created yet
|
||||
@@ -92,7 +118,7 @@ import Foundation
|
||||
guard let currentEventIdentifier = currentEventIdentifier else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
return coordinatorsForEventIdentifiers[currentEventIdentifier]
|
||||
}
|
||||
|
||||
@@ -101,11 +127,43 @@ import Foundation
|
||||
// ignore backwards events
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var coordinator = coordinatorsForEventIdentifiers.removeValue(forKey: event.redacts)
|
||||
|
||||
|
||||
coordinator?.toPresentable().dismiss(animated: false) {
|
||||
coordinator = nil
|
||||
coordinator = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification handling
|
||||
|
||||
private func registerNotificationObservers() {
|
||||
self.sessionStateDidChangeObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.mxSessionStateDidChange, object: session, queue: nil) { [weak self] notification in
|
||||
guard let self else { return }
|
||||
guard let concernedSession = notification.object as? MXSession, self.session === concernedSession else { return }
|
||||
|
||||
self.update(sessionState: concernedSession.state)
|
||||
}
|
||||
}
|
||||
|
||||
private func unregisterNotificationObservers() {
|
||||
if let observer = self.sessionStateDidChangeObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session state
|
||||
private func update(sessionState: MXSessionState) {
|
||||
let oldState = self.sessionState
|
||||
self.sessionState = sessionState
|
||||
|
||||
switch (oldState, sessionState) {
|
||||
case (_, .homeserverNotReachable):
|
||||
pauseRecordingOnError()
|
||||
case (_, .running):
|
||||
pauseRecording()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -44,6 +44,9 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol {
|
||||
// MARK: Public
|
||||
|
||||
weak var serviceDelegate: VoiceBroadcastRecorderServiceDelegate?
|
||||
var isRecording: Bool {
|
||||
return audioEngine.isRunning
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
@@ -113,6 +116,8 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol {
|
||||
// Discard the service on VoiceBroadcastService error. We keep the service in case of other error type
|
||||
if error as? VoiceBroadcastServiceError != nil {
|
||||
self.tearDownVoiceBroadcastService()
|
||||
} else {
|
||||
AppDelegate.theDelegate().showError(asAlert: error)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -133,6 +138,10 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol {
|
||||
}
|
||||
}, failure: { error in
|
||||
MXLog.error("[VoiceBroadcastRecorderService] Failed to pause voice broadcast", context: error)
|
||||
// Pause voice broadcast recording without sending pending events.
|
||||
if error is VoiceBroadcastServiceError == false {
|
||||
AppDelegate.theDelegate().showError(asAlert: error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -148,6 +157,9 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
}, failure: { error in
|
||||
MXLog.error("[VoiceBroadcastRecorderService] Failed to resume voice broadcast", context: error)
|
||||
if error is VoiceBroadcastServiceError == false {
|
||||
AppDelegate.theDelegate().showError(asAlert: error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -166,6 +178,15 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol {
|
||||
self.tearDownVoiceBroadcastService()
|
||||
}
|
||||
|
||||
func pauseOnErrorRecordingVoiceBroadcast() {
|
||||
audioEngine.pause()
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
invalidateTimer()
|
||||
|
||||
// Update state
|
||||
self.serviceDelegate?.voiceBroadcastRecorderService(self, didUpdateState: .error)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
/// Reset chunk values.
|
||||
private func resetValues() {
|
||||
|
||||
+6
@@ -25,6 +25,9 @@ protocol VoiceBroadcastRecorderServiceProtocol {
|
||||
/// Service delegate
|
||||
var serviceDelegate: VoiceBroadcastRecorderServiceDelegate? { get set }
|
||||
|
||||
/// Returns if a voice broadcast is currently recording.
|
||||
var isRecording: Bool { get }
|
||||
|
||||
/// Start voice broadcast recording.
|
||||
func startRecordingVoiceBroadcast()
|
||||
|
||||
@@ -39,4 +42,7 @@ protocol VoiceBroadcastRecorderServiceProtocol {
|
||||
|
||||
/// Cancel voice broadcast recording after redacted it.
|
||||
func cancelRecordingVoiceBroadcast()
|
||||
|
||||
/// Pause voice broadcast recording without sending pending events.
|
||||
func pauseOnErrorRecordingVoiceBroadcast()
|
||||
}
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceBroadcastRecorderConnectionErrorView: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
HStack(spacing: 0) {
|
||||
Image(uiImage: Asset.Images.errorIcon.image)
|
||||
.frame(width: 40, height: 40)
|
||||
Text(VectorL10n.voiceBroadcastRecorderConnectionError)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(theme.fonts.caption1)
|
||||
.foregroundColor(theme.colors.alert)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceBroadcastRecorderConnectionErrorView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VoiceBroadcastRecorderConnectionErrorView()
|
||||
}
|
||||
}
|
||||
+43
-37
@@ -26,7 +26,7 @@ struct VoiceBroadcastRecorderView: View {
|
||||
@State private var showingStopAlert = false
|
||||
|
||||
private var backgroundColor: Color {
|
||||
if viewModel.viewState.recordingState != .paused {
|
||||
if viewModel.viewState.recordingState != .paused, viewModel.viewState.recordingState != .error {
|
||||
return theme.colors.alert
|
||||
}
|
||||
return theme.colors.quarterlyContent
|
||||
@@ -78,47 +78,53 @@ struct VoiceBroadcastRecorderView: View {
|
||||
.accessibilityIdentifier("liveButton")
|
||||
}
|
||||
|
||||
HStack(alignment: .top, spacing: 34.0) {
|
||||
Button {
|
||||
switch viewModel.viewState.recordingState {
|
||||
case .started, .resumed:
|
||||
viewModel.send(viewAction: .pause)
|
||||
case .stopped:
|
||||
viewModel.send(viewAction: .start)
|
||||
case .paused:
|
||||
viewModel.send(viewAction: .resume)
|
||||
if viewModel.viewState.recordingState == .error {
|
||||
VoiceBroadcastRecorderConnectionErrorView()
|
||||
} else {
|
||||
HStack(alignment: .top, spacing: 34.0) {
|
||||
Button {
|
||||
switch viewModel.viewState.recordingState {
|
||||
case .started, .resumed:
|
||||
viewModel.send(viewAction: .pause)
|
||||
case .stopped:
|
||||
viewModel.send(viewAction: .start)
|
||||
case .paused:
|
||||
viewModel.send(viewAction: .resume)
|
||||
case .error:
|
||||
break
|
||||
}
|
||||
} label: {
|
||||
if viewModel.viewState.recordingState == .started || viewModel.viewState.recordingState == .resumed {
|
||||
Image("voice_broadcast_record_pause")
|
||||
.renderingMode(.original)
|
||||
} else {
|
||||
Image("voice_broadcast_record")
|
||||
.renderingMode(.original)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if viewModel.viewState.recordingState == .started || viewModel.viewState.recordingState == .resumed {
|
||||
Image("voice_broadcast_record_pause")
|
||||
.renderingMode(.original)
|
||||
} else {
|
||||
Image("voice_broadcast_record")
|
||||
.accessibilityIdentifier("recordButton")
|
||||
|
||||
Button {
|
||||
showingStopAlert = true
|
||||
} label: {
|
||||
Image("voice_broadcast_stop")
|
||||
.renderingMode(.original)
|
||||
}
|
||||
.alert(isPresented:$showingStopAlert) {
|
||||
Alert(title: Text(VectorL10n.voiceBroadcastStopAlertTitle),
|
||||
message: Text(VectorL10n.voiceBroadcastStopAlertDescription),
|
||||
primaryButton: .cancel(),
|
||||
secondaryButton: .default(Text(VectorL10n.voiceBroadcastStopAlertAgreeButton),
|
||||
action: {
|
||||
viewModel.send(viewAction: .stop)
|
||||
}))
|
||||
}
|
||||
.accessibilityIdentifier("stopButton")
|
||||
.disabled(viewModel.viewState.recordingState == .stopped)
|
||||
.mask(Color.black.opacity(viewModel.viewState.recordingState == .stopped ? 0.3 : 1.0))
|
||||
}
|
||||
.accessibilityIdentifier("recordButton")
|
||||
|
||||
Button {
|
||||
showingStopAlert = true
|
||||
} label: {
|
||||
Image("voice_broadcast_stop")
|
||||
.renderingMode(.original)
|
||||
}
|
||||
.alert(isPresented:$showingStopAlert) {
|
||||
Alert(title: Text(VectorL10n.voiceBroadcastStopAlertTitle),
|
||||
message: Text(VectorL10n.voiceBroadcastStopAlertDescription),
|
||||
primaryButton: .cancel(),
|
||||
secondaryButton: .default(Text(VectorL10n.voiceBroadcastStopAlertAgreeButton),
|
||||
action: {
|
||||
viewModel.send(viewAction: .stop)
|
||||
}))
|
||||
}
|
||||
.accessibilityIdentifier("stopButton")
|
||||
.disabled(viewModel.viewState.recordingState == .stopped)
|
||||
.mask(Color.black.opacity(viewModel.viewState.recordingState == .stopped ? 0.3 : 1.0))
|
||||
.padding(EdgeInsets(top: 10.0, leading: 0.0, bottom: 10.0, trailing: 0.0))
|
||||
}
|
||||
.padding(EdgeInsets(top: 10.0, leading: 0.0, bottom: 10.0, trailing: 0.0))
|
||||
}
|
||||
.padding(EdgeInsets(top: 12.0, leading: 4.0, bottom: 12.0, trailing: 4.0))
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ enum VoiceBroadcastRecorderViewAction {
|
||||
case stop
|
||||
case pause
|
||||
case resume
|
||||
case pauseOnError
|
||||
}
|
||||
|
||||
enum VoiceBroadcastRecorderState {
|
||||
@@ -28,6 +29,7 @@ enum VoiceBroadcastRecorderState {
|
||||
case stopped
|
||||
case paused
|
||||
case resumed
|
||||
case error
|
||||
}
|
||||
|
||||
struct VoiceBroadcastRecorderDetails {
|
||||
|
||||
@@ -56,6 +56,8 @@ class VoiceBroadcastRecorderViewModel: VoiceBroadcastRecorderViewModelType, Voic
|
||||
pause()
|
||||
case .resume:
|
||||
resume()
|
||||
case .pauseOnError:
|
||||
pauseOnError()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +82,10 @@ class VoiceBroadcastRecorderViewModel: VoiceBroadcastRecorderViewModelType, Voic
|
||||
voiceBroadcastRecorderService.resumeRecordingVoiceBroadcast()
|
||||
}
|
||||
|
||||
private func pauseOnError() {
|
||||
voiceBroadcastRecorderService.pauseOnErrorRecordingVoiceBroadcast()
|
||||
}
|
||||
|
||||
private func updateRemainingTime(_ remainingTime: UInt) {
|
||||
state.currentRecordingState = VoiceBroadcastRecorderViewModel.currentRecordingState(from: remainingTime)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user