embedding swiftUI view

This commit is contained in:
Flavio Alescio 2023-01-23 16:31:55 +01:00
parent 0437838091
commit bbd5fb4060
7 changed files with 53 additions and 383 deletions

View file

@ -57,7 +57,7 @@ final class PollHistoryCoordinator: NSObject, Coordinator, Presentable {
}
func showPollDetail(_ poll: PollListData) {
let detailCoordinator: PollHistoryDetailCoordinator = .init(parameters: .init(pollHistoryDetails: .dummy, session: parameters.session, room: parameters.room))
let detailCoordinator: PollHistoryDetailCoordinator = .init(parameters: .init(pollHistoryDetails: MockPollHistoryDetailScreenState.openUndisclosed.poll, session: parameters.session, room: parameters.room))
detailCoordinator.toPresentable().presentationController?.delegate = self
detailCoordinator.completion = { [weak self, weak detailCoordinator] result in
guard let self = self, let coordinator = detailCoordinator else { return }

View file

@ -17,9 +17,10 @@
import CommonKit
import SwiftUI
import Combine
import MatrixSDK
struct PollHistoryDetailCoordinatorParameters {
let pollHistoryDetails: PollHistoryDetails
let pollHistoryDetails: TimelinePollDetails
let session: MXSession
let room: MXRoom
}
@ -28,7 +29,6 @@ final class PollHistoryDetailCoordinator: Coordinator, Presentable {
private let parameters: PollHistoryDetailCoordinatorParameters
private let pollHistoryDetailHostingController: UIViewController
private var pollHistoryDetailViewModel: PollHistoryDetailViewModelProtocol
private let selectedAnswerIdentifiersSubject = PassthroughSubject<[String], Never>()
private var cancellables = Set<AnyCancellable>()
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
@ -40,6 +40,12 @@ final class PollHistoryDetailCoordinator: Coordinator, Presentable {
init(parameters: PollHistoryDetailCoordinatorParameters) {
self.parameters = parameters
// let event: MXEvent = .init()
// do {
// let timelinePollCoordinator = try TimelinePollCoordinator(parameters: .init(session: parameters.session, room: parameters.room, pollEvent: event))
// } catch {
// MXLog.debug("[PollHistoryDetailCoordinator] initKeys: Failed to init TimelinePollCoordinator with event: \(error.localizedDescription)")
// }
let viewModel = PollHistoryDetailViewModel(pollHistoryDetails: parameters.pollHistoryDetails)
let view = PollHistoryDetail(viewModel: viewModel.context)
pollHistoryDetailViewModel = viewModel
@ -51,30 +57,10 @@ final class PollHistoryDetailCoordinator: Coordinator, Presentable {
viewModel.completion = { [weak self] result in
guard let self = self else { return }
switch result {
case .selectedAnswerOptionsWithIdentifiers(let identifiers):
self.selectedAnswerIdentifiersSubject.send(identifiers)
case .dismiss:
self.completion?(.dismiss)
}
}
selectedAnswerIdentifiersSubject
.debounce(for: 2.0, scheduler: RunLoop.main)
.removeDuplicates()
.sink { [weak self] identifiers in
guard let self = self else { return }
// self.parameters.room.sendPollResponse(for: parameters.pollEvent,
// withAnswerIdentifiers: identifiers,
// threadId: nil,
// localEcho: nil, success: nil) { [weak self] error in
// guard let self = self else { return }
//
// MXLog.error("[TimelinePollCoordinator]] Failed submitting response", context: error)
//
// self.viewModel.showAnsweringFailure()
// }
}
.store(in: &cancellables)
}
// MARK: - Public

View file

@ -25,15 +25,15 @@ enum MockPollHistoryDetailScreenState: MockScreenState, CaseIterable {
case closedPollEnded
var screenType: Any.Type {
PollHistoryDetails.self
TimelinePollDetails.self
}
var poll: PollHistoryDetails {
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 = PollHistoryDetails(question: "Question",
let poll = TimelinePollDetails(question: "Question",
answerOptions: answerOptions,
closed: self == .closedDisclosed || self == .closedUndisclosed ? true : false,
totalAnswerCount: 20,
@ -47,7 +47,6 @@ enum MockPollHistoryDetailScreenState: MockScreenState, CaseIterable {
var screenView: ([Any], AnyView) {
let viewModel = PollHistoryDetailViewModel(pollHistoryDetails: poll)
return ([viewModel], AnyView(PollHistoryDetail(viewModel: viewModel.context)))

View file

@ -21,68 +21,20 @@ import Foundation
typealias PollHistoryDetailViewModelCallback = (PollHistoryDetailViewModelResult) -> Void
enum PollHistoryDetailViewModelResult {
case selectedAnswerOptionsWithIdentifiers([String])
case dismiss
}
// MARK: View model
struct PollHistoryDetails {
public static let dummy: PollHistoryDetails = MockPollHistoryDetailScreenState.openUndisclosed.poll
var question: String
var answerOptions: [TimelinePollAnswerOption]
var closed: Bool
var totalAnswerCount: UInt
var type: TimelinePollType
var eventType: TimelinePollEventType
var maxAllowedSelections: UInt
var hasBeenEdited = true
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
}
var shouldDiscloseResults: Bool {
if closed {
return totalAnswerCount > 0
} else {
return type == .disclosed && totalAnswerCount > 0 && hasCurrentUserVoted
}
}
var representsPollEndedEvent: Bool {
eventType == .ended
}
}
// MARK: View
struct PollHistoryDetailViewState: BindableState {
var poll: PollHistoryDetails
var poll: TimelinePollDetails
var timelineViewModel: TimelinePollViewModel
}
enum PollHistoryDetailViewAction {
case selectAnswerOptionWithIdentifier(String)
case dismiss
}

View file

@ -25,91 +25,24 @@ class PollHistoryDetailViewModel: PollHistoryDetailViewModelType, PollHistoryDet
// MARK: Private
// MARK: Public
var completion: PollHistoryDetailViewModelCallback?
// MARK: - Setup
init(pollHistoryDetails: PollHistoryDetails) {
super.init(initialViewState: PollHistoryDetailViewState(poll: pollHistoryDetails))
init(pollHistoryDetails: TimelinePollDetails) {
super.init(initialViewState: PollHistoryDetailViewState(poll: pollHistoryDetails, timelineViewModel: TimelinePollViewModel(timelinePollDetails: pollHistoryDetails)))
}
// MARK: - Public
override func process(viewAction: PollHistoryDetailViewAction) {
switch viewAction {
case .selectAnswerOptionWithIdentifier(let identifier):
guard !state.poll.closed else {
return
}
if state.poll.maxAllowedSelections == 1 {
updateSingleSelectPollLocalState(selectedAnswerIdentifier: identifier, callback: completion)
} else {
updateMultiSelectPollLocalState(&state, selectedAnswerIdentifier: identifier, callback: completion)
}
case .dismiss:
completion?(.dismiss)
}
}
// MARK: - TimelinePollViewModelProtocol
func updateWithPollDetails(_ pollDetails: PollHistoryDetails) {
state.poll = pollDetails
}
func updateSingleSelectPollLocalState(selectedAnswerIdentifier: String, callback: PollHistoryDetailViewModelCallback?) {
state.poll.answerOptions.updateEach { answerOption in
if answerOption.selected {
answerOption.selected = false
answerOption.count = UInt(max(0, Int(answerOption.count) - 1))
state.poll.totalAnswerCount = UInt(max(0, Int(state.poll.totalAnswerCount) - 1))
}
if answerOption.id == selectedAnswerIdentifier {
answerOption.selected = true
answerOption.count += 1
state.poll.totalAnswerCount += 1
}
}
informCoordinatorOfSelectionUpdate(state: state, callback: callback)
}
func updateMultiSelectPollLocalState(_ state: inout PollHistoryDetailViewState, selectedAnswerIdentifier: String, callback: PollHistoryDetailViewModelCallback?) {
let selectedAnswerOptions = state.poll.answerOptions.filter { $0.selected == true }
let isDeselecting = selectedAnswerOptions.filter { $0.id == selectedAnswerIdentifier }.count > 0
if !isDeselecting, selectedAnswerOptions.count >= state.poll.maxAllowedSelections {
return
}
state.poll.answerOptions.updateEach { answerOption in
if answerOption.id != selectedAnswerIdentifier {
return
}
if answerOption.selected {
answerOption.selected = false
answerOption.count = UInt(max(0, Int(answerOption.count) - 1))
state.poll.totalAnswerCount = UInt(max(0, Int(state.poll.totalAnswerCount) - 1))
} else {
answerOption.selected = true
answerOption.count += 1
state.poll.totalAnswerCount += 1
}
}
informCoordinatorOfSelectionUpdate(state: state, callback: callback)
}
func informCoordinatorOfSelectionUpdate(state: PollHistoryDetailViewState, callback: PollHistoryDetailViewModelCallback?) {
let selectedIdentifiers = state.poll.answerOptions.compactMap { answerOption in
answerOption.selected ? answerOption.id : nil
}
callback?(.selectedAnswerOptionsWithIdentifiers(selectedIdentifiers))
}
}

View file

@ -28,45 +28,41 @@ struct PollHistoryDetail: View {
@ObservedObject var viewModel: PollHistoryDetailViewModel.Context
var body: some View {
let poll = viewModel.viewState.poll
VStack(alignment: .leading, spacing: 16.0) {
if poll.representsPollEndedEvent {
Text(VectorL10n.pollTimelineEndedText)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.tertiaryContent)
navigation
.padding([.horizontal], 16)
.padding([.top, .bottom])
.background(theme.colors.background.ignoresSafeArea())
}
private var navigation: some View {
if #available(iOS 16.0, *) {
return NavigationStack {
content
}
Text(poll.question)
.font(theme.fonts.bodySB)
.foregroundColor(theme.colors.primaryContent) +
Text(editedText)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
VStack(spacing: 24.0) {
ForEach(poll.answerOptions) { answerOption in
PollHistoryDetailAnswerOptionButton(poll: poll, answerOption: answerOption) {
viewModel.send(viewAction: .selectAnswerOptionWithIdentifier(answerOption.id))
}
}
} else {
return NavigationView {
content
}
}
}
private var content: some View {
let timelineViewModel = viewModel.viewState.timelineViewModel
return TimelinePollView(viewModel: timelineViewModel.context)
.navigationTitle(navigationTitle)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.navigationBarItems(leading: btnBack)
}
private var btnBack : some View { Button(action: {
viewModel.send(viewAction: .dismiss)
}) {
HStack {
Image(systemName: "xmark") //"chevron.left"
.aspectRatio(contentMode: .fit)
.foregroundColor(theme.colors.accent)
}
.disabled(poll.closed)
.fixedSize(horizontal: false, vertical: true)
Text(totalVotesString)
.lineLimit(2)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.tertiaryContent)
}
.padding([.horizontal], 16)
.padding([.top, .bottom])
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(theme.colors.background.ignoresSafeArea())
.navigationTitle(navigationTitle)
// .alert(item: $viewModel.alertInfo) { info in
// info.alert
// }
}
private var navigationTitle: String {
@ -77,39 +73,6 @@ struct PollHistoryDetail: View {
return VectorL10n.pollHistoryActiveSegmentTitle
}
}
private var totalVotesString: String {
let poll = viewModel.viewState.poll
if poll.hasDecryptionError, poll.totalAnswerCount > 0 {
return VectorL10n.pollTimelineDecryptionError
}
if poll.closed {
if poll.totalAnswerCount == 1 {
return VectorL10n.pollTimelineTotalFinalResultsOneVote
} else {
return VectorL10n.pollTimelineTotalFinalResults(Int(poll.totalAnswerCount))
}
}
switch poll.totalAnswerCount {
case 0:
return VectorL10n.pollTimelineTotalNoVotes
case 1:
return (poll.hasCurrentUserVoted || poll.type == .undisclosed ?
VectorL10n.pollTimelineTotalOneVote :
VectorL10n.pollTimelineTotalOneVoteNotVoted)
default:
return (poll.hasCurrentUserVoted || poll.type == .undisclosed ?
VectorL10n.pollTimelineTotalVotes(Int(poll.totalAnswerCount)) :
VectorL10n.pollTimelineTotalVotesNotVoted(Int(poll.totalAnswerCount)))
}
}
private var editedText: String {
viewModel.viewState.poll.hasBeenEdited ? " \(VectorL10n.eventFormatterMessageEditedMention)" : ""
}
}
// MARK: - Previews

View file

@ -1,163 +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 PollHistoryDetailAnswerOptionButton: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
let poll: PollHistoryDetails
let answerOption: TimelinePollAnswerOption
let action: () -> Void
// MARK: Public
var body: some View {
Button(action: action) {
let rect = RoundedRectangle(cornerRadius: 4.0)
answerOptionLabel
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8.0)
.padding(.top, 12.0)
.padding(.bottom, 12.0)
.clipShape(rect)
.overlay(rect.stroke(borderAccentColor, lineWidth: 1.0))
.accentColor(progressViewAccentColor)
}
.accessibilityIdentifier("PollAnswerOption\(optionIndex)")
}
var answerOptionLabel: some View {
VStack(alignment: .leading, spacing: 12.0) {
HStack(alignment: .top, spacing: 8.0) {
if !poll.closed {
Image(uiImage: answerOption.selected ? Asset.Images.pollCheckboxSelected.image : Asset.Images.pollCheckboxDefault.image)
}
Text(answerOption.text)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
.accessibilityIdentifier("PollAnswerOption\(optionIndex)Label")
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")
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")
}
}
}
}
}
var borderAccentColor: Color {
guard !poll.closed else {
return (answerOption.winner ? theme.colors.accent : theme.colors.quinaryContent)
}
return answerOption.selected ? theme.colors.accent : theme.colors.quinaryContent
}
var progressViewAccentColor: Color {
guard !poll.closed else {
return (answerOption.winner ? theme.colors.accent : theme.colors.quarterlyContent)
}
return answerOption.selected ? theme.colors.accent : theme.colors.quarterlyContent
}
var optionIndex: Int {
poll.answerOptions.firstIndex { $0.id == answerOption.id } ?? Int.max
}
}
struct PollHistoryDetailAnswerOptionButton_Previews: PreviewProvider {
static let stateRenderer = MockPollHistoryDetailScreenState.stateRenderer
static var previews: some View {
Group {
let pollTypes: [TimelinePollType] = [.disclosed, .undisclosed]
ForEach(pollTypes, id: \.self) { type in
VStack {
TimelinePollAnswerOptionButton(poll: buildPoll(closed: false, type: type),
answerOption: buildAnswerOption(selected: false),
action: { })
TimelinePollAnswerOptionButton(poll: buildPoll(closed: false, type: type),
answerOption: buildAnswerOption(selected: true),
action: { })
TimelinePollAnswerOptionButton(poll: buildPoll(closed: true, type: type),
answerOption: buildAnswerOption(selected: false, winner: false),
action: { })
TimelinePollAnswerOptionButton(poll: buildPoll(closed: true, type: type),
answerOption: buildAnswerOption(selected: false, winner: true),
action: { })
TimelinePollAnswerOptionButton(poll: buildPoll(closed: true, type: type),
answerOption: buildAnswerOption(selected: true, winner: false),
action: { })
TimelinePollAnswerOptionButton(poll: buildPoll(closed: true, type: type),
answerOption: buildAnswerOption(selected: true, winner: true),
action: { })
let longText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
TimelinePollAnswerOptionButton(poll: buildPoll(closed: true, type: type),
answerOption: buildAnswerOption(text: longText, selected: true, winner: true),
action: { })
}
}
}
}
static func buildPoll(closed: Bool, type: TimelinePollType) -> TimelinePollDetails {
TimelinePollDetails(question: "",
answerOptions: [],
closed: closed,
totalAnswerCount: 100,
type: type,
eventType: .started,
maxAllowedSelections: 1,
hasBeenEdited: false,
hasDecryptionError: false)
}
static func buildAnswerOption(text: String = "Test", selected: Bool, winner: Bool = false) -> TimelinePollAnswerOption {
TimelinePollAnswerOption(id: "1", text: text, count: 5, winner: winner, selected: selected)
}
}