mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
Refine polls UI in the timeline (#1474)
* Add summary view in PollRoomTimelineView * Add selectedPollOption action * Handle undisclosed polls * Add more logics in PollRoomTimelineView * Refine ended poll UI * Refine poll layout * More UI refinements * Fix layout issue * Refine progress bar * Add poll mocks and UI tests * Cleanup
This commit is contained in:
@@ -41,8 +41,8 @@ internal enum Asset {
|
||||
internal static let locationPin = ImageAsset(name: "images/location-pin")
|
||||
internal static let locationPointerFull = ImageAsset(name: "images/location-pointer-full")
|
||||
internal static let locationPointer = ImageAsset(name: "images/location-pointer")
|
||||
internal static let equalizer = ImageAsset(name: "images/equalizer")
|
||||
internal static let timelineComposerSendMessage = ImageAsset(name: "images/timeline-composer-send-message")
|
||||
internal static let timelinePoll = ImageAsset(name: "images/timeline-poll")
|
||||
internal static let timelineReactionAddMore = ImageAsset(name: "images/timeline-reaction-add-more")
|
||||
internal static let waitingGradient = ImageAsset(name: "images/waiting-gradient")
|
||||
}
|
||||
|
||||
@@ -208,6 +208,16 @@ public enum L10n {
|
||||
public static var commonPeople: String { return L10n.tr("Localizable", "common_people") }
|
||||
/// Permalink
|
||||
public static var commonPermalink: String { return L10n.tr("Localizable", "common_permalink") }
|
||||
/// Final votes: %1$@
|
||||
public static func commonPollFinalVotes(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "common_poll_final_votes", String(describing: p1))
|
||||
}
|
||||
/// Total votes: %1$@
|
||||
public static func commonPollTotalVotes(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "common_poll_total_votes", String(describing: p1))
|
||||
}
|
||||
/// Results will show after the poll has ended
|
||||
public static var commonPollUndisclosedText: String { return L10n.tr("Localizable", "common_poll_undisclosed_text") }
|
||||
/// Plural format key: "%#@COUNT@"
|
||||
public static func commonPollVotesCount(_ p1: Int) -> String {
|
||||
return L10n.tr("Localizable", "common_poll_votes_count", p1)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
extension Poll {
|
||||
static func mock(question: String,
|
||||
pollKind: Poll.Kind = .disclosed,
|
||||
options: [Poll.Option],
|
||||
votes: [String: [String]] = [:],
|
||||
ended: Bool = false) -> Self {
|
||||
.init(question: question,
|
||||
kind: pollKind,
|
||||
maxSelections: 1,
|
||||
options: options,
|
||||
votes: votes,
|
||||
endDate: ended ? Date() : nil)
|
||||
}
|
||||
|
||||
static var disclosed: Self {
|
||||
mock(question: "What country do you like most?",
|
||||
pollKind: .disclosed,
|
||||
options: [.mock(text: "Italy 🇮🇹", votes: 5, allVotes: 10, isWinning: true),
|
||||
.mock(text: "China 🇨🇳", votes: 3, allVotes: 10),
|
||||
.mock(text: "USA 🇺🇸", votes: 2, allVotes: 10)])
|
||||
}
|
||||
|
||||
static var undisclosed: Self {
|
||||
mock(question: "What country do you like most?",
|
||||
pollKind: .undisclosed,
|
||||
options: [.mock(text: "Italy 🇮🇹", votes: 5, allVotes: 10, isWinning: true),
|
||||
.mock(text: "China 🇨🇳", votes: 3, allVotes: 10, isSelected: true),
|
||||
.mock(text: "USA 🇺🇸", votes: 2, allVotes: 10)])
|
||||
}
|
||||
|
||||
static var endedDisclosed: Self {
|
||||
mock(question: "What country do you like most?",
|
||||
pollKind: .disclosed,
|
||||
options: [.mock(text: "Italy 🇮🇹", votes: 5, allVotes: 10, isWinning: true),
|
||||
.mock(text: "China 🇨🇳", votes: 3, allVotes: 10, isSelected: true),
|
||||
.mock(text: "USA 🇺🇸", votes: 2, allVotes: 10)],
|
||||
ended: true)
|
||||
}
|
||||
|
||||
static var endedUndisclosed: Self {
|
||||
mock(question: "What country do you like most?",
|
||||
pollKind: .undisclosed,
|
||||
options: [.mock(text: "Italy 🇮🇹", votes: 5, allVotes: 10, isWinning: true),
|
||||
.mock(text: "China 🇨🇳", votes: 3, allVotes: 10, isSelected: true),
|
||||
.mock(text: "USA 🇺🇸", votes: 2, allVotes: 10)],
|
||||
ended: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension Poll.Option {
|
||||
static func mock(text: String, votes: Int = 0, allVotes: Int = 0, isSelected: Bool = false, isWinning: Bool = false) -> Self {
|
||||
.init(id: UUID().uuidString,
|
||||
text: text,
|
||||
votes: votes,
|
||||
allVotes: allVotes,
|
||||
isSelected: isSelected,
|
||||
isWinning: isWinning)
|
||||
}
|
||||
}
|
||||
|
||||
extension PollRoomTimelineItem {
|
||||
static func mock(poll: Poll) -> Self {
|
||||
.init(id: .random,
|
||||
poll: poll,
|
||||
body: "poll",
|
||||
timestamp: "Now",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
sender: .init(id: "userID"),
|
||||
properties: .init())
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ enum RoomScreenViewAction {
|
||||
case toggleReaction(key: String, itemID: TimelineItemIdentifier)
|
||||
case sendReadReceiptIfNeeded(TimelineItemIdentifier)
|
||||
case paginateBackwards
|
||||
case selectedPollOption(poll: Poll, optionID: String)
|
||||
|
||||
case timelineItemMenu(itemID: TimelineItemIdentifier)
|
||||
case timelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction)
|
||||
|
||||
@@ -139,6 +139,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
if state.swiftUITimelineEnabled {
|
||||
renderPendingTimelineItems()
|
||||
}
|
||||
case .selectedPollOption:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,19 +18,26 @@ import SwiftUI
|
||||
|
||||
struct PollOptionView: View {
|
||||
let pollOption: Poll.Option
|
||||
let showVotes: Bool
|
||||
let isFinalResult: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
FormRowAccessory(kind: .multipleSelection(isSelected: pollOption.isSelected))
|
||||
|
||||
VStack(spacing: 10) {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text(pollOption.text)
|
||||
.font(isFinalWinningOption ? .compound.bodyLGSemibold : .compound.bodyLG)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Text(L10n.commonPollVotesCount(pollOption.votes))
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
if showVotes {
|
||||
Text(L10n.commonPollVotesCount(pollOption.votes))
|
||||
.font(isFinalWinningOption ? .compound.bodySMSemibold : .compound.bodySM)
|
||||
.foregroundColor(isFinalWinningOption ? .compound.textPrimary : .compound.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
progressView
|
||||
@@ -41,8 +48,39 @@ struct PollOptionView: View {
|
||||
// MARK: - Private
|
||||
|
||||
private var progressView: some View {
|
||||
ProgressView(value: Double(pollOption.votes) / Double(pollOption.allVotes))
|
||||
.progressViewStyle(LinearProgressViewStyle(tint: .compound.textPrimary))
|
||||
PollProgressView(progress: progress)
|
||||
}
|
||||
|
||||
private var progress: Double {
|
||||
switch (showVotes, pollOption.allVotes, pollOption.isSelected) {
|
||||
case (true, let allVotes, _) where allVotes > 0:
|
||||
return Double(pollOption.votes) / Double(allVotes)
|
||||
case (false, _, true):
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private var isFinalWinningOption: Bool {
|
||||
pollOption.isWinning && isFinalResult
|
||||
}
|
||||
}
|
||||
|
||||
private struct PollProgressView: View {
|
||||
let progress: Double
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.foregroundColor(.compound.borderDisabled)
|
||||
|
||||
Capsule()
|
||||
.frame(maxWidth: progress * geometry.size.width)
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,13 +92,28 @@ struct PollOptionView_Previews: PreviewProvider {
|
||||
text: "Italian 🇮🇹",
|
||||
votes: 1,
|
||||
allVotes: 10,
|
||||
isSelected: true))
|
||||
isSelected: true,
|
||||
isWinning: false),
|
||||
showVotes: false,
|
||||
isFinalResult: false)
|
||||
|
||||
PollOptionView(pollOption: .init(id: "2",
|
||||
text: "Chinese 🇨🇳",
|
||||
votes: 9,
|
||||
allVotes: 10,
|
||||
isSelected: false))
|
||||
isSelected: false,
|
||||
isWinning: true),
|
||||
showVotes: true,
|
||||
isFinalResult: false)
|
||||
|
||||
PollOptionView(pollOption: .init(id: "2",
|
||||
text: "Chinese 🇨🇳",
|
||||
votes: 9,
|
||||
allVotes: 10,
|
||||
isSelected: false,
|
||||
isWinning: true),
|
||||
showVotes: true,
|
||||
isFinalResult: true)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import SwiftUI
|
||||
struct PollRoomTimelineView: View {
|
||||
let timelineItem: PollRoomTimelineItem
|
||||
@Environment(\.timelineStyle) var timelineStyle
|
||||
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||
@ScaledMetric private var summaryPadding = 32
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
@@ -26,10 +28,18 @@ struct PollRoomTimelineView: View {
|
||||
questionView
|
||||
|
||||
ForEach(poll.options, id: \.id) { option in
|
||||
Button { } label: {
|
||||
PollOptionView(pollOption: option)
|
||||
Button {
|
||||
context.send(viewAction: .selectedPollOption(poll: poll, optionID: option.id))
|
||||
} label: {
|
||||
PollOptionView(pollOption: option,
|
||||
showVotes: showVotes,
|
||||
isFinalResult: poll.hasEnded)
|
||||
.foregroundColor(progressBarColor(for: option))
|
||||
}
|
||||
.disabled(poll.hasEnded)
|
||||
}
|
||||
|
||||
summaryView
|
||||
}
|
||||
.frame(maxWidth: 450)
|
||||
}
|
||||
@@ -42,50 +52,104 @@ struct PollRoomTimelineView: View {
|
||||
}
|
||||
|
||||
private var questionView: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(Asset.Images.equalizer.name)
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(Asset.Images.timelinePoll.name)
|
||||
|
||||
Text(poll.question)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.compound.bodyLGSemibold)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var summaryView: some View {
|
||||
if let summaryText = poll.summaryText {
|
||||
Text(summaryText)
|
||||
.font(.compound.bodySM)
|
||||
.padding(.leading, showVotes ? 0 : summaryPadding)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: showVotes ? .trailing : .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private func progressBarColor(for option: Poll.Option) -> Color {
|
||||
if poll.hasEnded {
|
||||
return option.isWinning ? .compound.textActionAccent : .compound.textDisabled
|
||||
} else {
|
||||
return .compound.textPrimary
|
||||
}
|
||||
}
|
||||
|
||||
private var showVotes: Bool {
|
||||
poll.hasEnded || poll.kind == .disclosed
|
||||
}
|
||||
}
|
||||
|
||||
private extension Poll {
|
||||
var summaryText: String? {
|
||||
guard !hasEnded else {
|
||||
return options.first.map {
|
||||
L10n.commonPollFinalVotes($0.allVotes)
|
||||
}
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case .disclosed:
|
||||
return options.first.map {
|
||||
L10n.commonPollTotalVotes($0.allVotes)
|
||||
}
|
||||
case .undisclosed:
|
||||
return L10n.commonPollUndisclosedText
|
||||
}
|
||||
}
|
||||
|
||||
var hasEnded: Bool {
|
||||
endDate != nil
|
||||
}
|
||||
}
|
||||
|
||||
struct PollRoomTimelineView_Previews: PreviewProvider {
|
||||
static let viewModel = RoomScreenViewModel.mock
|
||||
|
||||
static var previews: some View {
|
||||
PollRoomTimelineView(timelineItem: .init(id: .random,
|
||||
poll: .mock,
|
||||
body: "Foo",
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "Bob"),
|
||||
properties: .init()))
|
||||
PollRoomTimelineView(timelineItem: .mock(poll: .disclosed))
|
||||
.environment(\.timelineStyle, .bubbles)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Poll bubble style")
|
||||
.previewDisplayName("Disclosed, Bubble")
|
||||
|
||||
PollRoomTimelineView(timelineItem: .init(id: .random,
|
||||
poll: .mock,
|
||||
body: "Foo",
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "Bob"),
|
||||
properties: .init()))
|
||||
PollRoomTimelineView(timelineItem: .mock(poll: .undisclosed))
|
||||
.environment(\.timelineStyle, .bubbles)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Undisclosed, Bubble")
|
||||
|
||||
PollRoomTimelineView(timelineItem: .mock(poll: .endedDisclosed))
|
||||
.environment(\.timelineStyle, .bubbles)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Ended, Disclosed, Bubble")
|
||||
|
||||
PollRoomTimelineView(timelineItem: .mock(poll: .endedUndisclosed))
|
||||
.environment(\.timelineStyle, .bubbles)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Ended, Undisclosed, Bubble")
|
||||
|
||||
PollRoomTimelineView(timelineItem: .mock(poll: .disclosed))
|
||||
.environment(\.timelineStyle, .plain)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Poll plain style")
|
||||
.previewDisplayName("Disclosed, Plain")
|
||||
|
||||
PollRoomTimelineView(timelineItem: .mock(poll: .undisclosed))
|
||||
.environment(\.timelineStyle, .plain)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Undisclosed, Plain")
|
||||
|
||||
PollRoomTimelineView(timelineItem: .mock(poll: .endedDisclosed))
|
||||
.environment(\.timelineStyle, .plain)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Ended, Disclosed, Plain")
|
||||
|
||||
PollRoomTimelineView(timelineItem: .mock(poll: .endedUndisclosed))
|
||||
.environment(\.timelineStyle, .plain)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Ended, Undisclosed, Plain")
|
||||
}
|
||||
}
|
||||
|
||||
private extension Poll {
|
||||
static let mock: Self = .init(question: "Do you like polls?",
|
||||
pollKind: .disclosed,
|
||||
maxSelections: 1,
|
||||
options: [.init(id: "1", text: "Yes", votes: 1, allVotes: 3, isSelected: true), .init(id: "2", text: "No", votes: 2, allVotes: 3, isSelected: false)],
|
||||
votes: [:],
|
||||
endDate: nil)
|
||||
}
|
||||
|
||||
@@ -212,6 +212,16 @@ enum RoomTimelineItemFixtures {
|
||||
TextRoomTimelineItem(text: "Pork buffalo mollit culpa strip steak in leberkas flank cow.",
|
||||
senderDisplayName: "Alice")]
|
||||
}
|
||||
|
||||
static var disclosedPolls: [RoomTimelineItemProtocol] {
|
||||
[PollRoomTimelineItem.mock(poll: .disclosed),
|
||||
PollRoomTimelineItem.mock(poll: .endedDisclosed)]
|
||||
}
|
||||
|
||||
static var undisclosedPolls: [RoomTimelineItemProtocol] {
|
||||
[PollRoomTimelineItem.mock(poll: .undisclosed),
|
||||
PollRoomTimelineItem.mock(poll: .endedUndisclosed)]
|
||||
}
|
||||
}
|
||||
|
||||
private extension TextRoomTimelineItem {
|
||||
|
||||
+2
-1
@@ -29,7 +29,7 @@ struct PollRoomTimelineItem: Equatable, EventBasedTimelineItemProtocol {
|
||||
|
||||
struct Poll: Equatable {
|
||||
let question: String
|
||||
let pollKind: Kind
|
||||
let kind: Kind
|
||||
let maxSelections: Int
|
||||
let options: [Option]
|
||||
let votes: [String: [String]]
|
||||
@@ -46,5 +46,6 @@ struct Poll: Equatable {
|
||||
let votes: Int
|
||||
let allVotes: Int
|
||||
let isSelected: Bool
|
||||
let isWinning: Bool
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,16 +333,21 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
count + pair.value.count
|
||||
}
|
||||
|
||||
let maxOptionVotes = votes.map(\.value.count).max()
|
||||
|
||||
let options = answers.map { answer in
|
||||
Poll.Option(id: answer.id,
|
||||
text: answer.text,
|
||||
votes: votes[answer.id]?.count ?? 0,
|
||||
allVotes: allVotes,
|
||||
isSelected: votes[answer.id]?.contains(userID) ?? false)
|
||||
let optionVotesCount = votes[answer.id]?.count
|
||||
|
||||
return Poll.Option(id: answer.id,
|
||||
text: answer.text,
|
||||
votes: optionVotesCount ?? 0,
|
||||
allVotes: allVotes,
|
||||
isSelected: votes[answer.id]?.contains(userID) ?? false,
|
||||
isWinning: optionVotesCount.map { $0 == maxOptionVotes } ?? false)
|
||||
}
|
||||
|
||||
let poll = Poll(question: question,
|
||||
pollKind: .init(pollKind: pollKind),
|
||||
kind: .init(pollKind: pollKind),
|
||||
maxSelections: Int(maxSelections),
|
||||
options: options,
|
||||
votes: votes,
|
||||
|
||||
@@ -322,6 +322,20 @@ class MockScreen: Identifiable {
|
||||
emojiProvider: EmojiProvider())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
case .roomWithDisclosedPolls, .roomWithUndisclosedPolls:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let timelineController = MockRoomTimelineController()
|
||||
timelineController.timelineItems = id == .roomWithDisclosedPolls ? RoomTimelineItemFixtures.disclosedPolls : RoomTimelineItemFixtures.undisclosedPolls
|
||||
timelineController.incomingItems = []
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Polls timeline", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
case .sessionVerification:
|
||||
|
||||
@@ -45,6 +45,8 @@ enum UITestsScreenIdentifier: String {
|
||||
case roomLayoutTop
|
||||
case roomLayoutMiddle
|
||||
case roomLayoutBottom
|
||||
case roomWithDisclosedPolls
|
||||
case roomWithUndisclosedPolls
|
||||
case sessionVerification
|
||||
case userSessionScreen
|
||||
case roomDetailsScreen
|
||||
|
||||
Reference in New Issue
Block a user