Merge branch 'develop' into flescio/1040-poll_detail

# Conflicts:
#	Riot/Assets/en.lproj/Vector.strings
#	Riot/Generated/Strings.swift
This commit is contained in:
Flavio Alescio
2023-01-25 15:49:40 +01:00
95 changed files with 714 additions and 957 deletions
@@ -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 }
}
@@ -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?()
}
}
@@ -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 {
@@ -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
@@ -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
}
}
}