mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-04-22 17:42:45 +02:00
Merge branch 'develop' into flescio/1040-poll_detail
# Conflicts: # Riot/Assets/en.lproj/Vector.strings # Riot/Generated/Strings.swift
This commit is contained in:
@@ -1,43 +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 Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum AllChatsOnboardingViewModelResult {
|
||||
case cancel
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct AllChatsOnboardingPageData: Identifiable {
|
||||
let id = UUID().uuidString
|
||||
let image: UIImage
|
||||
let title: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct AllChatsOnboardingViewState: BindableState {
|
||||
let pages: [AllChatsOnboardingPageData]
|
||||
}
|
||||
|
||||
enum AllChatsOnboardingViewAction {
|
||||
case cancel
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +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 Foundation
|
||||
|
||||
protocol AllChatsOnboardingViewModelProtocol {
|
||||
var completion: ((AllChatsOnboardingViewModelResult) -> Void)? { get set }
|
||||
static func makeAllChatsOnboardingViewModel() -> AllChatsOnboardingViewModelProtocol
|
||||
var context: AllChatsOnboardingViewModelType.Context { get }
|
||||
}
|
||||
-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)
|
||||
}
|
||||
}
|
||||
@@ -47,17 +47,17 @@ enum MockPollHistoryScreenState: MockScreenState, CaseIterable {
|
||||
pollHistoryMode = .past
|
||||
case .activeEmpty:
|
||||
pollHistoryMode = .active
|
||||
pollService.nextPublisher = Empty(completeImmediately: true,
|
||||
pollService.nextBatchPublisher = Empty(completeImmediately: true,
|
||||
outputType: TimelinePollDetails.self,
|
||||
failureType: Error.self).eraseToAnyPublisher()
|
||||
case .pastEmpty:
|
||||
pollHistoryMode = .past
|
||||
pollService.nextPublisher = Empty(completeImmediately: true,
|
||||
pollService.nextBatchPublisher = Empty(completeImmediately: true,
|
||||
outputType: TimelinePollDetails.self,
|
||||
failureType: Error.self).eraseToAnyPublisher()
|
||||
case .loading:
|
||||
pollHistoryMode = .active
|
||||
pollService.nextPublisher = Empty(completeImmediately: false,
|
||||
pollService.nextBatchPublisher = Empty(completeImmediately: false,
|
||||
outputType: TimelinePollDetails.self,
|
||||
failureType: Error.self).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ private extension PollHistoryViewModel {
|
||||
state.isLoading = true
|
||||
|
||||
pollService
|
||||
.next()
|
||||
.nextBatch()
|
||||
.collect()
|
||||
.sink { [weak self] _ in
|
||||
#warning("Handle errors")
|
||||
@@ -69,7 +69,7 @@ private extension PollHistoryViewModel {
|
||||
pollService
|
||||
.updates
|
||||
.sink { [weak self] detail in
|
||||
self?.updatePolls(with: detail)
|
||||
self?.update(poll: detail)
|
||||
self?.updateViewState()
|
||||
}
|
||||
.store(in: &subcriptions)
|
||||
@@ -82,7 +82,7 @@ private extension PollHistoryViewModel {
|
||||
.store(in: &subcriptions)
|
||||
}
|
||||
|
||||
func updatePolls(with poll: TimelinePollDetails) {
|
||||
func update(poll: TimelinePollDetails) {
|
||||
guard let pollIndex = polls?.firstIndex(where: { $0.id == poll.id }) else {
|
||||
return
|
||||
}
|
||||
@@ -103,3 +103,20 @@ private extension PollHistoryViewModel {
|
||||
state.polls = renderedPolls?.sorted(by: { $0.startDate > $1.startDate })
|
||||
}
|
||||
}
|
||||
|
||||
extension PollHistoryViewModel.Context {
|
||||
var emptyPollsText: String {
|
||||
let days = PollHistoryConstants.chunkSizeInDays
|
||||
|
||||
switch (viewState.bindings.mode, viewState.canLoadMoreContent) {
|
||||
case (.active, true):
|
||||
return VectorL10n.pollHistoryNoActivePollPeriodText("\(days)")
|
||||
case (.active, false):
|
||||
return VectorL10n.pollHistoryNoActivePollText
|
||||
case (.past, true):
|
||||
return VectorL10n.pollHistoryNoPastPollPeriodText("\(days)")
|
||||
case (.past, false):
|
||||
return VectorL10n.pollHistoryNoPastPollText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ final class PollHistoryService: PollHistoryServiceProtocol {
|
||||
setup(timeline: timeline)
|
||||
}
|
||||
|
||||
func next() -> AnyPublisher<TimelinePollDetails, Error> {
|
||||
func nextBatch() -> AnyPublisher<TimelinePollDetails, Error> {
|
||||
currentBatchSubject?.eraseToAnyPublisher() ?? startPagination()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,13 @@ final class MockPollHistoryService: PollHistoryServiceProtocol {
|
||||
pollErrorPublisher
|
||||
}
|
||||
|
||||
lazy var nextPublisher: AnyPublisher<TimelinePollDetails, Error> = (activePollsData + pastPollsData)
|
||||
lazy var nextBatchPublisher: AnyPublisher<TimelinePollDetails, Error> = (activePollsData + pastPollsData)
|
||||
.publisher
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
func next() -> AnyPublisher<TimelinePollDetails, Error> {
|
||||
nextPublisher
|
||||
func nextBatch() -> AnyPublisher<TimelinePollDetails, Error> {
|
||||
nextBatchPublisher
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -18,13 +18,13 @@ import Combine
|
||||
|
||||
protocol PollHistoryServiceProtocol {
|
||||
/// Returns a Publisher publishing the polls in the next batch.
|
||||
/// Implementations should return the same publisher if `next()` is called again before the previous publisher completes.
|
||||
func next() -> AnyPublisher<TimelinePollDetails, Error>
|
||||
/// 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 `next()` publishers.
|
||||
/// Publishes updates for the polls previously pusblished by the `nextBatch()` publishers.
|
||||
var updates: AnyPublisher<TimelinePollDetails, Never> { get }
|
||||
|
||||
/// Publishes errors regarding poll aggregations.
|
||||
/// Note: `next()` will continue to publish new polls even if some poll isn't being aggregated correctly.
|
||||
/// Note: `nextBatch()` will continue to publish new polls even if some poll isn't being aggregated correctly.
|
||||
var pollErrors: AnyPublisher<Error, Never> { get }
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ final class PollHistoryUITests: MockScreenTestCase {
|
||||
let emptyText = app.staticTexts["PollHistory.emptyText"]
|
||||
let items = app.staticTexts["PollListItem.title"]
|
||||
let selectedSegment = app.buttons[VectorL10n.pollHistoryPastSegmentTitle]
|
||||
let winningOption = app.staticTexts["PollListData.winningOption"]
|
||||
let winningOption = app.buttons["PollAnswerOption0"]
|
||||
|
||||
XCTAssertEqual(title, VectorL10n.pollHistoryTitle)
|
||||
XCTAssertTrue(items.exists)
|
||||
@@ -53,7 +53,7 @@ final class PollHistoryUITests: MockScreenTestCase {
|
||||
func testPastPollHistoryIsEmpty() {
|
||||
app.goToScreenWithIdentifier(MockPollHistoryScreenState.pastEmpty.title)
|
||||
let title = app.navigationBars.firstMatch.identifier
|
||||
let emptyText = app.staticTexts["PollHistory.emptyLoadMoreText"]
|
||||
let emptyText = app.staticTexts["PollHistory.emptyText"]
|
||||
let items = app.staticTexts["PollListItem.title"]
|
||||
let selectedSegment = app.buttons[VectorL10n.pollHistoryPastSegmentTitle]
|
||||
let winningOption = app.staticTexts["PollListData.winningOption"]
|
||||
|
||||
@@ -42,7 +42,7 @@ final class PollHistoryViewModelTests: XCTestCase {
|
||||
|
||||
func testLoadingStateIsTrueWhileLoading() {
|
||||
XCTAssertFalse(viewModel.state.isLoading)
|
||||
pollHistoryService.nextPublisher = Empty(completeImmediately: false, outputType: TimelinePollDetails.self, failureType: Error.self).eraseToAnyPublisher()
|
||||
pollHistoryService.nextBatchPublisher = Empty(completeImmediately: false, outputType: TimelinePollDetails.self, failureType: Error.self).eraseToAnyPublisher()
|
||||
viewModel.process(viewAction: .viewAppeared)
|
||||
XCTAssertTrue(viewModel.state.isLoading)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ final class PollHistoryViewModelTests: XCTestCase {
|
||||
viewModel.process(viewAction: .viewAppeared)
|
||||
|
||||
var firstPoll = try XCTUnwrap(try polls.first)
|
||||
XCTAssertEqual(firstPoll.question, "Do you like the active poll number 9?")
|
||||
XCTAssertEqual(firstPoll.question, "Do you like the active poll number 1?")
|
||||
firstPoll.question = "foo"
|
||||
|
||||
mockUpdates.send(firstPoll)
|
||||
|
||||
@@ -85,42 +85,32 @@ struct PollHistory: View {
|
||||
Button {
|
||||
#warning("handle action in next ticket")
|
||||
} label: {
|
||||
Text("Load more polls")
|
||||
Text(VectorL10n.pollHistoryLoadMore)
|
||||
.font(theme.fonts.body)
|
||||
}
|
||||
.disabled(viewModel.viewState.isLoading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var spinner: some View {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var noPollsView: some View {
|
||||
if viewModel.viewState.canLoadMoreContent {
|
||||
let days = PollHistoryConstants.chunkSizeInDays
|
||||
|
||||
VStack(spacing: 32) {
|
||||
Text(viewModel.mode == .active ? VectorL10n.pollHistoryNoActivePollPeriodText("\(days)") : VectorL10n.pollHistoryNoPastPollPeriodText("\(days)"))
|
||||
.font(theme.fonts.body)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 16)
|
||||
.accessibilityIdentifier("PollHistory.emptyLoadMoreText")
|
||||
|
||||
loadMoreButton
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
} else {
|
||||
Text(viewModel.mode == .active ? VectorL10n.pollHistoryNoActivePollText : VectorL10n.pollHistoryNoPastPollText)
|
||||
VStack(spacing: 32) {
|
||||
Text(viewModel.emptyPollsText)
|
||||
.font(theme.fonts.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.horizontal, 16)
|
||||
.accessibilityIdentifier("PollHistory.emptyText")
|
||||
|
||||
if viewModel.viewState.canLoadMoreContent {
|
||||
loadMoreButton
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
|
||||
@@ -48,56 +48,17 @@ struct PollListItem: View {
|
||||
if pollData.closed {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
let winningOptions = pollData.answerOptions.filter(\.winner)
|
||||
|
||||
ForEach(winningOptions) {
|
||||
optionView(winningOption: $0)
|
||||
TimelinePollAnswerOptionButton(poll: pollData, answerOption: $0, action: nil)
|
||||
}
|
||||
|
||||
resultView
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var clipShape: some Shape {
|
||||
RoundedRectangle(cornerRadius: 4.0)
|
||||
}
|
||||
|
||||
private func optionView(winningOption: TimelinePollAnswerOption) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12.0) {
|
||||
HStack(alignment: .top, spacing: 8.0) {
|
||||
Text(winningOption.text)
|
||||
.font(theme.fonts.body)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.accessibilityIdentifier("PollListData.winningOption")
|
||||
|
||||
Spacer()
|
||||
|
||||
votesText(winningOption: winningOption)
|
||||
}
|
||||
|
||||
ProgressView(value: Double(winningOption.count),
|
||||
total: Double(pollData.totalAnswerCount))
|
||||
.progressViewStyle(LinearProgressViewStyle())
|
||||
.scaleEffect(x: 1.0, y: 1.2, anchor: .center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 8.0)
|
||||
.padding(.top, 12.0)
|
||||
.padding(.bottom, 12.0)
|
||||
.clipShape(clipShape)
|
||||
.overlay(clipShape.stroke(theme.colors.accent, lineWidth: 1.0))
|
||||
.accentColor(theme.colors.accent)
|
||||
}
|
||||
|
||||
private func votesText(winningOption: TimelinePollAnswerOption) -> some View {
|
||||
Label {
|
||||
Text(winningOption.count == 1 ? VectorL10n.pollTimelineOneVote : VectorL10n.pollTimelineVotesCount(Int(winningOption.count)))
|
||||
.font(theme.fonts.footnote)
|
||||
.foregroundColor(theme.colors.accent)
|
||||
} icon: {
|
||||
Image(uiImage: Asset.Images.pollWinnerIcon.image)
|
||||
}
|
||||
}
|
||||
|
||||
private var resultView: some View {
|
||||
let text = pollData.totalAnswerCount == 1 ? VectorL10n.pollTimelineTotalFinalResultsOneVote : VectorL10n.pollTimelineTotalFinalResults(Int(pollData.totalAnswerCount))
|
||||
|
||||
@@ -133,8 +94,7 @@ struct PollListItem_Previews: PreviewProvider {
|
||||
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)],
|
||||
|
||||
@@ -139,17 +139,21 @@ extension TimelinePollDetails {
|
||||
closed: poll.isClosed,
|
||||
startDate: poll.startDate,
|
||||
totalAnswerCount: poll.totalAnswerCount,
|
||||
type: Self.pollKindToTimelinePollType(poll.kind),
|
||||
type: poll.kind.timelinePollType,
|
||||
eventType: eventType,
|
||||
maxAllowedSelections: poll.maxAllowedSelections,
|
||||
hasBeenEdited: poll.hasBeenEdited,
|
||||
hasDecryptionError: poll.hasDecryptionError)
|
||||
}
|
||||
|
||||
private static func pollKindToTimelinePollType(_ kind: PollKind) -> TimelinePollType {
|
||||
let mapping = [PollKind.disclosed: TimelinePollType.disclosed,
|
||||
PollKind.undisclosed: TimelinePollType.undisclosed]
|
||||
|
||||
return mapping[kind] ?? .disclosed
|
||||
}
|
||||
|
||||
private extension PollKind {
|
||||
var timelinePollType: TimelinePollType {
|
||||
switch self {
|
||||
case .disclosed:
|
||||
return .disclosed
|
||||
case .undisclosed:
|
||||
return .undisclosed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,33 +71,9 @@ struct TimelinePollDetails {
|
||||
var type: TimelinePollType
|
||||
var eventType: TimelinePollEventType
|
||||
var maxAllowedSelections: UInt
|
||||
var hasBeenEdited = true
|
||||
var hasBeenEdited: Bool
|
||||
var hasDecryptionError: Bool
|
||||
|
||||
init(id: String,
|
||||
question: String,
|
||||
answerOptions: [TimelinePollAnswerOption],
|
||||
closed: Bool,
|
||||
startDate: Date,
|
||||
totalAnswerCount: UInt,
|
||||
type: TimelinePollType,
|
||||
eventType: TimelinePollEventType,
|
||||
maxAllowedSelections: UInt,
|
||||
hasBeenEdited: Bool,
|
||||
hasDecryptionError: Bool) {
|
||||
self.id = id
|
||||
self.question = question
|
||||
self.answerOptions = answerOptions
|
||||
self.closed = closed
|
||||
self.startDate = startDate
|
||||
self.totalAnswerCount = totalAnswerCount
|
||||
self.type = type
|
||||
self.eventType = eventType
|
||||
self.maxAllowedSelections = maxAllowedSelections
|
||||
self.hasBeenEdited = hasBeenEdited
|
||||
self.hasDecryptionError = hasDecryptionError
|
||||
}
|
||||
|
||||
var hasCurrentUserVoted: Bool {
|
||||
answerOptions.contains(where: \.selected)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -60,23 +63,20 @@ struct TimelinePollAnswerOptionButton: View {
|
||||
Spacer()
|
||||
Image(uiImage: Asset.Images.pollWinnerIcon.image)
|
||||
}
|
||||
|
||||
if poll.shouldDiscloseResults {
|
||||
Text(answerOption.count == 1 ? VectorL10n.pollTimelineOneVote : VectorL10n.pollTimelineVotesCount(Int(answerOption.count)))
|
||||
.font(theme.fonts.footnote)
|
||||
.foregroundColor(poll.closed && answerOption.winner ? theme.colors.accent : theme.colors.secondaryContent)
|
||||
.accessibilityIdentifier("PollAnswerOption\(optionIndex)Count")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
if poll.shouldDiscloseResults {
|
||||
Text(answerOption.count == 1 ? VectorL10n.pollTimelineOneVote : VectorL10n.pollTimelineVotesCount(Int(answerOption.count)))
|
||||
.font(theme.fonts.footnote)
|
||||
.foregroundColor(poll.closed && answerOption.winner ? theme.colors.accent : theme.colors.secondaryContent)
|
||||
.accessibilityIdentifier("PollAnswerOption\(optionIndex)Count")
|
||||
}
|
||||
}
|
||||
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,6 +143,7 @@ struct TimelinePollAnswerOptionButton_Previews: PreviewProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
static func buildPoll(closed: Bool, type: TimelinePollType) -> TimelinePollDetails {
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+46
-1
@@ -32,6 +32,9 @@ import Foundation
|
||||
coordinatorsForEventIdentifiers.removeAll()
|
||||
}
|
||||
}
|
||||
didSet {
|
||||
sessionState = session?.state
|
||||
}
|
||||
}
|
||||
private var coordinatorsForEventIdentifiers = [String: VoiceBroadcastRecorderCoordinator]() {
|
||||
didSet {
|
||||
@@ -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
|
||||
|
||||
@@ -121,4 +134,36 @@ import Foundation
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user