Merge branch 'develop' into ismail/5068_start_thread

This commit is contained in:
ismailgulek
2022-01-19 00:07:52 +03:00
72 changed files with 1053 additions and 673 deletions

View File

@@ -375,6 +375,6 @@ final class BuildSettings: NSObject {
return false
}
return false
return true
}
}

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "poll_type_checkbox_default.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "poll_type_checkbox_default@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "poll_type_checkbox_default@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "poll_type_checkbox_selected.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "poll_type_checkbox_selected@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "poll_type_checkbox_selected@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1839,6 +1839,8 @@ Tap the + to start adding people.";
"poll_edit_form_create_poll" = "Create poll";
"poll_edit_form_poll_type" = "Poll type";
"poll_edit_form_poll_question_or_topic" = "Poll question or topic";
"poll_edit_form_question_or_topic" = "Question or topic";
@@ -1855,6 +1857,18 @@ Tap the + to start adding people.";
"poll_edit_form_post_failure_subtitle" = "Please try again";
"poll_edit_form_update_failure_title" = "Failed to update poll";
"poll_edit_form_update_failure_subtitle" = "Please try again";
"poll_edit_form_poll_type_open" = "Open poll";
"poll_edit_form_poll_type_open_description" = "Voters see results as soon as they have voted";
"poll_edit_form_poll_type_closed" = "Closed poll";
"poll_edit_form_poll_type_closed_description" = "Results are only revealed when you end the poll";
"poll_timeline_one_vote" = "1 vote";
"poll_timeline_votes_count" = "%lu votes";

View File

@@ -156,6 +156,8 @@ internal enum Asset {
internal static let pollDeleteOptionIcon = ImageAsset(name: "poll_delete_option_icon")
internal static let pollEditIcon = ImageAsset(name: "poll_edit_icon")
internal static let pollEndIcon = ImageAsset(name: "poll_end_icon")
internal static let pollTypeCheckboxDefault = ImageAsset(name: "poll_type_checkbox_default")
internal static let pollTypeCheckboxSelected = ImageAsset(name: "poll_type_checkbox_selected")
internal static let pollWinnerIcon = ImageAsset(name: "poll_winner_icon")
internal static let threadsFilter = ImageAsset(name: "threads_filter")
internal static let urlPreviewClose = ImageAsset(name: "url_preview_close")

View File

@@ -2539,6 +2539,26 @@ public class VectorL10n: NSObject {
public static var pollEditFormPollQuestionOrTopic: String {
return VectorL10n.tr("Vector", "poll_edit_form_poll_question_or_topic")
}
/// Poll type
public static var pollEditFormPollType: String {
return VectorL10n.tr("Vector", "poll_edit_form_poll_type")
}
/// Closed poll
public static var pollEditFormPollTypeClosed: String {
return VectorL10n.tr("Vector", "poll_edit_form_poll_type_closed")
}
/// Results are only revealed when you end the poll
public static var pollEditFormPollTypeClosedDescription: String {
return VectorL10n.tr("Vector", "poll_edit_form_poll_type_closed_description")
}
/// Open poll
public static var pollEditFormPollTypeOpen: String {
return VectorL10n.tr("Vector", "poll_edit_form_poll_type_open")
}
/// Voters see results as soon as they have voted
public static var pollEditFormPollTypeOpenDescription: String {
return VectorL10n.tr("Vector", "poll_edit_form_poll_type_open_description")
}
/// Please try again
public static var pollEditFormPostFailureSubtitle: String {
return VectorL10n.tr("Vector", "poll_edit_form_post_failure_subtitle")
@@ -2552,6 +2572,14 @@ public class VectorL10n: NSObject {
return VectorL10n.tr("Vector", "poll_edit_form_question_or_topic")
}
/// Please try again
public static var pollEditFormUpdateFailureSubtitle: String {
return VectorL10n.tr("Vector", "poll_edit_form_update_failure_subtitle")
}
/// Failed to update poll
public static var pollEditFormUpdateFailureTitle: String {
return VectorL10n.tr("Vector", "poll_edit_form_update_failure_title")
}
/// Please try again
public static var pollTimelineNotClosedSubtitle: String {
return VectorL10n.tr("Vector", "poll_timeline_not_closed_subtitle")
}

View File

@@ -188,10 +188,7 @@ final class RiotSettings: NSObject {
@UserDefault(key: "roomScreenAllowFilesAction", defaultValue: BuildSettings.roomScreenAllowFilesAction, storage: defaults)
var roomScreenAllowFilesAction
@UserDefault(key: "roomScreenAllowPollsAction", defaultValue: false, storage: defaults)
var roomScreenAllowPollsAction
@UserDefault(key: "roomScreenAllowLocationAction", defaultValue: false, storage: defaults)
var roomScreenAllowLocationAction

View File

@@ -2089,8 +2089,8 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) {
[self removeEventWithEventId:eventId];
if (event.isVoiceMessage) {
NSNumber *duration = event.content[kMXMessageContentKeyExtensibleAudio][kMXMessageContentKeyExtensibleAudioDuration];
NSArray<NSNumber *> *samples = event.content[kMXMessageContentKeyExtensibleAudio][kMXMessageContentKeyExtensibleAudioWaveform];
NSNumber *duration = event.content[kMXMessageContentKeyExtensibleAudioMSC1767][kMXMessageContentKeyExtensibleAudioDuration];
NSArray<NSNumber *> *samples = event.content[kMXMessageContentKeyExtensibleAudioMSC1767][kMXMessageContentKeyExtensibleAudioWaveform];
[self sendVoiceMessage:localFileURL mimeType:mimetype duration:duration.doubleValue samples:samples success:success failure:failure];
} else {

View File

@@ -1577,6 +1577,11 @@ static NSString *const kHTMLATagRegexPattern = @"<a href=\"(.*?)\">([^<]*)</a>";
}
case MXEventTypePollStart:
{
if (event.isEditEvent)
{
return nil;
}
displayText = [MXEventContentPollStart modelFromJSON:event.content].question;
break;
}

View File

@@ -279,6 +279,10 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat
if (self.tag == RoomBubbleCellDataTagPoll)
{
if (self.events.lastObject.isEditEvent) {
return YES;
}
return NO;
}

View File

@@ -81,7 +81,7 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol {
self.activityIndicatorPresenter = ActivityIndicatorPresenter()
if #available(iOS 14, *) {
PollTimelineProvider.shared.session = parameters.session
TimelinePollProvider.shared.session = parameters.session
}
super.init()
@@ -289,6 +289,29 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol {
navigationRouter.present(coordinator, animated: true)
coordinator.start()
}
private func startEditPollCoordinator(startEvent: MXEvent? = nil) {
guard #available(iOS 14.0, *) else {
return
}
let parameters = PollEditFormCoordinatorParameters(room: roomViewController.roomDataSource.room, pollStartEvent: startEvent)
let coordinator = PollEditFormCoordinator(parameters: parameters)
coordinator.completion = { [weak self, weak coordinator] in
guard let self = self, let coordinator = coordinator else {
return
}
self.navigationRouter?.dismissModule(animated: true, completion: nil)
self.remove(childCoordinator: coordinator)
}
add(childCoordinator: coordinator)
navigationRouter?.present(coordinator, animated: true)
coordinator.start()
}
}
// MARK: - RoomIdentifiable
@@ -352,26 +375,7 @@ extension RoomCoordinator: RoomViewControllerDelegate {
}
func roomViewControllerDidRequestPollCreationFormPresentation(_ roomViewController: RoomViewController) {
guard #available(iOS 14.0, *) else {
return
}
let parameters = PollEditFormCoordinatorParameters(room: roomViewController.roomDataSource.room)
let coordinator = PollEditFormCoordinator(parameters: parameters)
coordinator.completion = { [weak self, weak coordinator] in
guard let self = self, let coordinator = coordinator else {
return
}
self.navigationRouter?.dismissModule(animated: true, completion: nil)
self.remove(childCoordinator: coordinator)
}
add(childCoordinator: coordinator)
navigationRouter?.present(coordinator, animated: true)
coordinator.start()
startEditPollCoordinator()
}
func roomViewControllerDidRequestLocationSharingFormPresentation(_ roomViewController: RoomViewController) {
@@ -387,7 +391,7 @@ extension RoomCoordinator: RoomViewControllerDelegate {
return false
}
return PollTimelineProvider.shared.pollTimelineCoordinatorForEventIdentifier(eventIdentifier)?.canEndPoll() ?? false
return TimelinePollProvider.shared.timelinePollCoordinatorForEventIdentifier(eventIdentifier)?.canEndPoll() ?? false
}
func roomViewController(_ roomViewController: RoomViewController, endPollWithEventIdentifier eventIdentifier: String) {
@@ -395,6 +399,18 @@ extension RoomCoordinator: RoomViewControllerDelegate {
return
}
PollTimelineProvider.shared.pollTimelineCoordinatorForEventIdentifier(eventIdentifier)?.endPoll()
TimelinePollProvider.shared.timelinePollCoordinatorForEventIdentifier(eventIdentifier)?.endPoll()
}
func roomViewController(_ roomViewController: RoomViewController, canEditPollWithEventIdentifier eventIdentifier: String) -> Bool {
guard #available(iOS 14.0, *) else {
return false
}
return TimelinePollProvider.shared.timelinePollCoordinatorForEventIdentifier(eventIdentifier)?.canEditPoll() ?? false
}
func roomViewController(_ roomViewController: RoomViewController, didRequestEditForPollWithStart startEvent: MXEvent) {
startEditPollCoordinator(startEvent: startEvent)
}
}

View File

@@ -225,6 +225,12 @@ canEndPollWithEventIdentifier:(NSString *)eventIdentifier;
- (void)roomViewController:(RoomViewController *)roomViewController
endPollWithEventIdentifier:(NSString *)eventIdentifier;
- (BOOL)roomViewController:(RoomViewController *)roomViewController
canEditPollWithEventIdentifier:(NSString *)eventIdentifier;
- (void)roomViewController:(RoomViewController *)roomViewController
didRequestEditForPollWithStartEvent:(MXEvent *)startEvent;
@end
NS_ASSUME_NONNULL_END

View File

@@ -2090,7 +2090,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
[self roomInputToolbarViewDidTapFileUpload];
}]];
}
if (RiotSettings.shared.roomScreenAllowPollsAction)
if (BuildSettings.pollsEnabled)
{
[actionItems addObject:[[RoomActionItem alloc] initWithImage:[UIImage imageNamed:@"action_poll"] andAction:^{
MXStrongifyAndReturnIfNil(self);
@@ -6257,16 +6257,34 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
MXWeakify(self);
RoomContextualMenuItem *editMenuItem = [[RoomContextualMenuItem alloc] initWithMenuAction:RoomContextualMenuActionEdit];
editMenuItem.action = ^{
MXStrongifyAndReturnIfNil(self);
[self hideContextualMenuAnimated:YES cancelEventSelection:NO completion:nil];
[self editEventContentWithId:event.eventId];
// And display the keyboard
[self.inputToolbarView becomeFirstResponder];
};
editMenuItem.isEnabled = [self.roomDataSource canEditEventWithId:event.eventId];
switch (event.eventType) {
case MXEventTypePollStart: {
editMenuItem.action = ^{
MXStrongifyAndReturnIfNil(self);
[self hideContextualMenuAnimated:YES cancelEventSelection:YES completion:nil];
[self.delegate roomViewController:self didRequestEditForPollWithStartEvent:event];
};
editMenuItem.isEnabled = [self.delegate roomViewController:self canEditPollWithEventIdentifier:event.eventId];
break;
}
default: {
editMenuItem.action = ^{
MXStrongifyAndReturnIfNil(self);
[self hideContextualMenuAnimated:YES cancelEventSelection:NO completion:nil];
[self editEventContentWithId:event.eventId];
// And display the keyboard
[self.inputToolbarView becomeFirstResponder];
};
editMenuItem.isEnabled = [self.roomDataSource canEditEventWithId:event.eventId];
break;
}
}
return editMenuItem;
}

View File

@@ -29,7 +29,7 @@ class PollBubbleCell: SizableBaseBubbleCell, BubbleCellReactionsDisplayable {
let bubbleData = cellData as? RoomBubbleCellData,
let event = bubbleData.events.last,
event.eventType == __MXEventType.pollStart,
let view = PollTimelineProvider.shared.buildPollTimelineViewForEvent(event) else {
let view = TimelinePollProvider.shared.buildTimelinePollViewForEvent(event) else {
return
}

View File

@@ -164,7 +164,6 @@ typedef NS_ENUM(NSUInteger, ABOUT)
typedef NS_ENUM(NSUInteger, LABS_ENABLE)
{
LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX = 0,
LABS_ENABLE_POLLS,
LABS_ENABLE_THREADS_INDEX
};
@@ -580,7 +579,6 @@ TableViewSectionsDelegate>
{
Section *sectionLabs = [Section sectionWithTag:SECTION_TAG_LABS];
[sectionLabs addRowWithTag:LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX];
[sectionLabs addRowWithTag:LABS_ENABLE_POLLS];
[sectionLabs addRowWithTag:LABS_ENABLE_THREADS_INDEX];
sectionLabs.headerTitle = [VectorL10n settingsLabs];
if (sectionLabs.hasAnyRows)
@@ -2465,18 +2463,6 @@ TableViewSectionsDelegate>
cell = labelAndSwitchCell;
}
else if (row == LABS_ENABLE_POLLS && BuildSettings.pollsEnabled)
{
MXKTableViewCellWithLabelAndSwitch *labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath];
labelAndSwitchCell.mxkLabel.text = [VectorL10n settingsLabsEnabledPolls];
labelAndSwitchCell.mxkSwitch.on = RiotSettings.shared.roomScreenAllowPollsAction;
labelAndSwitchCell.mxkSwitch.onTintColor = ThemeService.shared.theme.tintColor;
[labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnablePolls:) forControlEvents:UIControlEventValueChanged];
cell = labelAndSwitchCell;
}
else if (row == LABS_ENABLE_THREADS_INDEX)
{
MXKTableViewCellWithLabelAndSwitch *labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath];
@@ -3221,11 +3207,6 @@ TableViewSectionsDelegate>
RiotSettings.shared.enableRingingForGroupCalls = sender.isOn;
}
- (void)toggleEnablePolls:(UISwitch *)sender
{
RiotSettings.shared.roomScreenAllowPollsAction = sender.isOn;
}
- (void)toggleEnableThreads:(UISwitch *)sender
{
RiotSettings.shared.enableThreads = sender.isOn;

View File

@@ -427,7 +427,7 @@ class NotificationService: UNNotificationServiceExtension {
if event.isReply() {
let parser = MXReplyEventParser()
let replyParts = parser.parse(event)
notificationBody = replyParts.bodyParts.replyText
notificationBody = replyParts?.bodyParts.replyText
} else {
notificationBody = messageContent
}

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh AnalyticsPrompt AnalyticsPrompt
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh AnalyticsPrompt AnalyticsPrompt
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,20 +1,18 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh AnalyticsPrompt AnalyticsPrompt
/*
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.
*/
//
// 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

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh AnalyticsPrompt AnalyticsPrompt
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh AnalyticsPrompt AnalyticsPrompt
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh AnalyticsPrompt AnalyticsPrompt
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -24,7 +24,7 @@ enum MockAppScreens {
MockAnalyticsPromptScreenState.self,
MockUserSuggestionScreenState.self,
MockPollEditFormScreenState.self,
MockPollTimelineScreenState.self,
MockTimelinePollScreenState.self,
MockTemplateUserProfileScreenState.self,
MockTemplateRoomListScreenState.self,
MockTemplateRoomChatScreenState.self

View File

@@ -66,11 +66,11 @@ struct LocationSharingViewState: BindableState {
}
struct LocationSharingViewStateBindings {
var alertInfo: ErrorAlertInfo?
var alertInfo: LocationSharingErrorAlertInfo?
var userLocation: CLLocationCoordinate2D?
}
struct ErrorAlertInfo: Identifiable {
struct LocationSharingErrorAlertInfo: Identifiable {
enum AlertType {
case mapLoadingError
case userLocatingError

View File

@@ -25,7 +25,7 @@ enum MockLocationSharingScreenState: MockScreenState, CaseIterable {
case displayExistingLocation
var screenType: Any.Type {
MockLocationSharingScreenState.self
LocationSharingView.self
}
var screenView: ([Any], AnyView) {

View File

@@ -72,24 +72,24 @@ class LocationSharingViewModel: LocationSharingViewModelType {
switch error {
case .failedLoadingMap:
state.bindings.alertInfo = ErrorAlertInfo(id: .mapLoadingError,
title: VectorL10n.locationSharingLoadingMapErrorTitle(AppInfo.current.displayName) ,
primaryButton: (VectorL10n.ok, { completion?(.cancel) }),
secondaryButton: nil)
state.bindings.alertInfo = LocationSharingErrorAlertInfo(id: .mapLoadingError,
title: VectorL10n.locationSharingLoadingMapErrorTitle(AppInfo.current.displayName) ,
primaryButton: (VectorL10n.ok, { completion?(.cancel) }),
secondaryButton: nil)
case .failedLocatingUser:
state.bindings.alertInfo = ErrorAlertInfo(id: .userLocatingError,
title: VectorL10n.locationSharingLocatingUserErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.ok, { completion?(.cancel) }),
secondaryButton: nil)
state.bindings.alertInfo = LocationSharingErrorAlertInfo(id: .userLocatingError,
title: VectorL10n.locationSharingLocatingUserErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.ok, { completion?(.cancel) }),
secondaryButton: nil)
case .invalidLocationAuthorization:
state.bindings.alertInfo = ErrorAlertInfo(id: .authorizationError,
title: VectorL10n.locationSharingInvalidAuthorizationErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.locationSharingInvalidAuthorizationNotNow, { completion?(.cancel) }),
secondaryButton: (VectorL10n.locationSharingInvalidAuthorizationSettings, {
if let applicationSettingsURL = URL(string:UIApplication.openSettingsURLString) {
UIApplication.shared.open(applicationSettingsURL)
}
}))
state.bindings.alertInfo = LocationSharingErrorAlertInfo(id: .authorizationError,
title: VectorL10n.locationSharingInvalidAuthorizationErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.locationSharingInvalidAuthorizationNotNow, { completion?(.cancel) }),
secondaryButton: (VectorL10n.locationSharingInvalidAuthorizationSettings, {
if let applicationSettingsURL = URL(string:UIApplication.openSettingsURLString) {
UIApplication.shared.open(applicationSettingsURL)
}
}))
default:
break
}
@@ -100,10 +100,10 @@ class LocationSharingViewModel: LocationSharingViewModelType {
state.showLoadingIndicator = false
if error != nil {
state.bindings.alertInfo = ErrorAlertInfo(id: .locationSharingError,
title: VectorL10n.locationSharingInvalidAuthorizationErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.ok, nil),
secondaryButton: nil)
state.bindings.alertInfo = LocationSharingErrorAlertInfo(id: .locationSharingError,
title: VectorL10n.locationSharingInvalidAuthorizationErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.ok, nil),
secondaryButton: nil)
}
}
}

View File

@@ -1,20 +1,18 @@
// File created from ScreenTemplate
// $ createScreen.sh Room/NotificationSettings RoomNotificationSettings
/*
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.
*/
//
// 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

View File

@@ -1,20 +1,18 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollEditForm PollEditForm
/*
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.
*/
//
// 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
@@ -22,6 +20,7 @@ import SwiftUI
struct PollEditFormCoordinatorParameters {
let room: MXRoom
let pollStartEvent: MXEvent?
}
final class PollEditFormCoordinator: Coordinator, Presentable {
@@ -40,7 +39,7 @@ final class PollEditFormCoordinator: Coordinator, Presentable {
}
// MARK: Public
var childCoordinators: [Coordinator] = []
var completion: (() -> Void)?
@@ -51,9 +50,20 @@ final class PollEditFormCoordinator: Coordinator, Presentable {
init(parameters: PollEditFormCoordinatorParameters) {
self.parameters = parameters
let viewModel = PollEditFormViewModel()
let view = PollEditForm(viewModel: viewModel.context)
var viewModel: PollEditFormViewModel
if let startEvent = parameters.pollStartEvent,
let pollContent = MXEventContentPollStart(fromJSON: startEvent.content) {
viewModel = PollEditFormViewModel(parameters: PollEditFormViewModelParameters(mode: .editing,
pollDetails: EditFormPollDetails(type: Self.pollKindKeyToDetailsType(pollContent.kind),
question: pollContent.question,
answerOptions: pollContent.answerOptions.map { $0.text })))
} else {
viewModel = PollEditFormViewModel(parameters: PollEditFormViewModelParameters(mode: .creation, pollDetails: .default))
}
let view = PollEditForm(viewModel: viewModel.context)
_pollEditFormViewModel = viewModel
pollEditFormHostingController = VectorHostingController(rootView: view)
}
@@ -70,16 +80,9 @@ final class PollEditFormCoordinator: Coordinator, Presentable {
switch result {
case .cancel:
self.completion?()
case .create(let question, let answerOptions):
var options = [MXEventContentPollStartAnswerOption]()
for answerOption in answerOptions {
options.append(MXEventContentPollStartAnswerOption(uuid: UUID().uuidString, text: answerOption))
}
case .create(let details):
let pollStartContent = MXEventContentPollStart(question: question,
kind: kMXMessageContentKeyExtensiblePollKindDisclosed,
maxSelections: 1,
answerOptions: options)
let pollStartContent = self.buildPollContentWithDetails(details)
self.pollEditFormViewModel.dispatch(action: .startLoading)
@@ -92,15 +95,72 @@ final class PollEditFormCoordinator: Coordinator, Presentable {
guard let self = self else { return }
MXLog.error("Failed creating poll with error: \(String(describing: error))")
self.pollEditFormViewModel.dispatch(action: .stopLoading(error))
self.pollEditFormViewModel.dispatch(action: .stopLoading(.failedCreatingPoll))
}
case .update(let details):
guard let pollStartEvent = self.parameters.pollStartEvent else {
fatalError()
}
self.pollEditFormViewModel.dispatch(action: .startLoading)
guard let oldPollContent = MXEventContentPollStart(fromJSON: pollStartEvent.content) else {
self.pollEditFormViewModel.dispatch(action: .stopLoading(.failedUpdatingPoll))
return
}
let newPollContent = self.buildPollContentWithDetails(details)
self.parameters.room.sendPollUpdate(for: pollStartEvent,
oldContent: oldPollContent,
newContent: newPollContent, localEcho: nil) { [weak self] result in
guard let self = self else { return }
self.pollEditFormViewModel.dispatch(action: .stopLoading(nil))
self.completion?()
} failure: { [weak self] error in
guard let self = self else { return }
MXLog.error("Failed updating poll with error: \(String(describing: error))")
self.pollEditFormViewModel.dispatch(action: .stopLoading(.failedUpdatingPoll))
}
}
}
}
// MARK: - Private
// MARK: - Presentable
func toPresentable() -> UIViewController {
return pollEditFormHostingController
}
// MARK: - Private
private func buildPollContentWithDetails(_ details: EditFormPollDetails) -> MXEventContentPollStart {
var options = [MXEventContentPollStartAnswerOption]()
for answerOption in details.answerOptions {
options.append(MXEventContentPollStartAnswerOption(uuid: UUID().uuidString, text: answerOption))
}
return MXEventContentPollStart(question: details.question,
kind: Self.pollDetailsTypeToKindKey(details.type),
maxSelections: NSNumber(value: details.maxSelections),
answerOptions: options)
}
private static func pollDetailsTypeToKindKey(_ type: EditFormPollType) -> String {
let mapping = [EditFormPollType.disclosed : kMXMessageContentKeyExtensiblePollKindDisclosed,
EditFormPollType.undisclosed : kMXMessageContentKeyExtensiblePollKindUndisclosed]
return mapping[type] ?? kMXMessageContentKeyExtensiblePollKindDisclosed
}
private static func pollKindKeyToDetailsType(_ key: String) -> EditFormPollType {
let mapping = [kMXMessageContentKeyExtensiblePollKindDisclosed : EditFormPollType.disclosed,
kMXMessageContentKeyExtensiblePollKindUndisclosed : EditFormPollType.undisclosed]
return mapping[key] ?? EditFormPollType.disclosed
}
}

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollEditForm PollEditForm
//
// Copyright 2021 New Vector Ltd
//
@@ -19,10 +17,31 @@
import Foundation
import SwiftUI
enum EditFormPollType {
case disclosed
case undisclosed
}
struct EditFormPollDetails {
let type: EditFormPollType
let question: String
let answerOptions: [String]
let maxSelections: UInt = 1
static var `default`: EditFormPollDetails {
EditFormPollDetails(type: .disclosed, question: "", answerOptions: ["", ""])
}
}
enum PollEditFormMode {
case creation
case editing
}
enum PollEditFormStateAction {
case viewAction(PollEditFormViewAction)
case startLoading
case stopLoading(Error?)
case stopLoading(PollEditFormErrorAlertInfo.AlertType?)
}
enum PollEditFormViewAction {
@@ -30,11 +49,13 @@ enum PollEditFormViewAction {
case deleteAnswerOption(PollEditFormAnswerOption)
case cancel
case create
case update
}
enum PollEditFormViewModelResult {
case cancel
case create(String, [String])
case create(EditFormPollDetails)
case update(EditFormPollDetails)
}
struct PollEditFormQuestion {
@@ -60,12 +81,14 @@ struct PollEditFormAnswerOption: Identifiable, Equatable {
}
struct PollEditFormViewState: BindableState {
var minAnswerOptionsCount: Int
var maxAnswerOptionsCount: Int
var mode: PollEditFormMode
var bindings: PollEditFormViewStateBindings
var confirmationButtonEnabled: Bool {
!bindings.question.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
bindings.answerOptions.filter({ !$0.text.isEmpty }).count >= 2
bindings.answerOptions.filter({ !$0.text.isEmpty }).count >= minAnswerOptionsCount
}
var addAnswerOptionButtonEnabled: Bool {
@@ -78,6 +101,18 @@ struct PollEditFormViewState: BindableState {
struct PollEditFormViewStateBindings {
var question: PollEditFormQuestion
var answerOptions: [PollEditFormAnswerOption]
var type: EditFormPollType
var showsFailureAlert: Bool = false
var alertInfo: PollEditFormErrorAlertInfo?
}
struct PollEditFormErrorAlertInfo: Identifiable {
enum AlertType {
case failedCreatingPoll
case failedUpdatingPoll
}
let id: AlertType
let title: String
let subtitle: String
}

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -24,11 +22,11 @@ enum MockPollEditFormScreenState: MockScreenState, CaseIterable {
case standard
var screenType: Any.Type {
MockPollEditFormScreenState.self
PollEditForm.self
}
var screenView: ([Any], AnyView) {
let viewModel = PollEditFormViewModel()
let viewModel = PollEditFormViewModel(parameters: PollEditFormViewModelParameters(mode: .creation, pollDetails: .default))
return ([viewModel], AnyView(PollEditForm(viewModel: viewModel.context)))
}
}

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollEditForm PollEditForm
//
// Copyright 2021 New Vector Ltd
//
@@ -19,6 +17,11 @@
import SwiftUI
import Combine
struct PollEditFormViewModelParameters {
let mode: PollEditFormMode
let pollDetails: EditFormPollDetails
}
@available(iOS 14, *)
typealias PollEditFormViewModelType = StateStoreViewModel< PollEditFormViewState,
PollEditFormStateAction,
@@ -27,6 +30,7 @@ typealias PollEditFormViewModelType = StateStoreViewModel< PollEditFormViewState
class PollEditFormViewModel: PollEditFormViewModelType {
private struct Constants {
static let minAnswerOptionsCount = 2
static let maxAnswerOptionsCount = 20
static let maxQuestionLength = 340
static let maxAnswerOptionLength = 340
@@ -42,20 +46,19 @@ class PollEditFormViewModel: PollEditFormViewModelType {
// MARK: - Setup
init() {
super.init(initialViewState: Self.defaultState())
}
private static func defaultState() -> PollEditFormViewState {
return PollEditFormViewState(
init(parameters: PollEditFormViewModelParameters) {
let state = PollEditFormViewState(
minAnswerOptionsCount: Constants.minAnswerOptionsCount,
maxAnswerOptionsCount: Constants.maxAnswerOptionsCount,
mode: parameters.mode,
bindings: PollEditFormViewStateBindings(
question: PollEditFormQuestion(text: "", maxLength: Constants.maxQuestionLength),
answerOptions: [PollEditFormAnswerOption(text: "", maxLength: Constants.maxAnswerOptionLength),
PollEditFormAnswerOption(text: "", maxLength: Constants.maxAnswerOptionLength)
]
question: PollEditFormQuestion(text: parameters.pollDetails.question, maxLength: Constants.maxQuestionLength),
answerOptions: parameters.pollDetails.answerOptions.map { PollEditFormAnswerOption(text: $0, maxLength: Constants.maxAnswerOptionLength) },
type: parameters.pollDetails.type
)
)
super.init(initialViewState: state)
}
// MARK: - Public
@@ -65,11 +68,9 @@ class PollEditFormViewModel: PollEditFormViewModelType {
case .cancel:
completion?(.cancel)
case .create:
completion?(.create(state.bindings.question.text.trimmingCharacters(in: .whitespacesAndNewlines),
state.bindings.answerOptions.compactMap({ answerOption in
let text = answerOption.text.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
})))
completion?(.create(buildPollDetails()))
case .update:
completion?(.update(buildPollDetails()))
default:
dispatch(action: .viewAction(viewAction))
}
@@ -92,10 +93,30 @@ class PollEditFormViewModel: PollEditFormViewModelType {
case .stopLoading(let error):
state.showLoadingIndicator = false
if error != nil {
state.bindings.showsFailureAlert = true
switch error {
case .failedCreatingPoll:
state.bindings.alertInfo = PollEditFormErrorAlertInfo(id: .failedCreatingPoll,
title: VectorL10n.pollEditFormPostFailureTitle,
subtitle: VectorL10n.pollEditFormPostFailureSubtitle)
case .failedUpdatingPoll:
state.bindings.alertInfo = PollEditFormErrorAlertInfo(id: .failedUpdatingPoll,
title: VectorL10n.pollEditFormUpdateFailureTitle,
subtitle: VectorL10n.pollEditFormUpdateFailureSubtitle)
case .none:
break
}
break
}
}
// MARK: - Private
private func buildPollDetails() -> EditFormPollDetails {
return EditFormPollDetails(type: state.bindings.type,
question: state.bindings.question.text.trimmingCharacters(in: .whitespacesAndNewlines),
answerOptions: state.bindings.answerOptions.compactMap({ answerOption in
let text = answerOption.text.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}))
}
}

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollEditForm PollEditForm
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollEditForm PollEditForm
//
// Copyright 2021 New Vector Ltd
//
@@ -28,10 +26,10 @@ class PollEditFormViewModelTests: XCTestCase {
var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
viewModel = PollEditFormViewModel()
viewModel = PollEditFormViewModel(parameters: PollEditFormViewModelParameters(mode: .creation, pollDetails: .default))
context = viewModel.context
}
func testInitialState() {
XCTAssertTrue(context.question.text.isEmpty)
XCTAssertFalse(context.viewState.confirmationButtonEnabled)
@@ -100,14 +98,14 @@ class PollEditFormViewModelTests: XCTestCase {
let thirdAnswer = " "
viewModel.completion = { result in
if case PollEditFormViewModelResult.create(let resultQuestion, let resultAnswerOptions) = result {
XCTAssertEqual(question.trimmingCharacters(in: .whitespacesAndNewlines), resultQuestion)
if case PollEditFormViewModelResult.create(let result) = result {
XCTAssertEqual(question.trimmingCharacters(in: .whitespacesAndNewlines), result.question)
// The last answer option should be automatically dropped as it's empty
XCTAssertEqual(resultAnswerOptions.count, 2)
XCTAssertEqual(result.answerOptions.count, 2)
XCTAssertEqual(resultAnswerOptions[0], firstAnswer.trimmingCharacters(in: .whitespacesAndNewlines))
XCTAssertEqual(resultAnswerOptions[1], secondAnswer.trimmingCharacters(in: .whitespacesAndNewlines))
XCTAssertEqual(result.answerOptions[0], firstAnswer.trimmingCharacters(in: .whitespacesAndNewlines))
XCTAssertEqual(result.answerOptions[1], secondAnswer.trimmingCharacters(in: .whitespacesAndNewlines))
}
}

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollEditForm PollEditForm
//
// Copyright 2021 New Vector Ltd
//
@@ -37,6 +35,9 @@ struct PollEditForm: View {
ScrollView {
VStack(alignment: .leading, spacing: 32.0) {
// Intentionally disabled until platform parity.
// PollEditFormTypePicker(selectedType: $viewModel.type)
VStack(alignment: .leading, spacing: 16.0) {
Text(VectorL10n.pollEditFormPollQuestionOrTopic)
.font(theme.fonts.title3SB)
@@ -58,7 +59,7 @@ struct PollEditForm: View {
ForEach(0..<viewModel.answerOptions.count, id: \.self) { index in
SafeBindingCollectionEnumerator($viewModel.answerOptions, index: index) { binding in
AnswerOptionGroup(text: binding.text, index: index) {
PollEditFormAnswerOptionView(text: binding.text, index: index) {
withAnimation(.easeInOut(duration: 0.2)) {
viewModel.send(viewAction: .deleteAnswerOption(viewModel.answerOptions[index]))
}
@@ -76,17 +77,20 @@ struct PollEditForm: View {
Spacer()
Button(VectorL10n.pollEditFormCreatePoll) {
viewModel.send(viewAction: .create)
if viewModel.viewState.mode == .creation {
Button(VectorL10n.pollEditFormCreatePoll) {
viewModel.send(viewAction: .create)
}
.buttonStyle(PrimaryActionButtonStyle())
.disabled(!viewModel.viewState.confirmationButtonEnabled)
}
.buttonStyle(PrimaryActionButtonStyle())
.disabled(!viewModel.viewState.confirmationButtonEnabled)
}
.padding()
.padding(.vertical, 24.0)
.padding(.horizontal, 16.0)
.activityIndicator(show: viewModel.viewState.showLoadingIndicator)
.alert(isPresented: $viewModel.showsFailureAlert) {
Alert(title: Text(VectorL10n.pollEditFormPostFailureTitle),
message: Text(VectorL10n.pollEditFormPostFailureSubtitle),
.alert(item: $viewModel.alertInfo) { info in
Alert(title: Text(info.title),
message: Text(info.subtitle),
dismissButton: .default(Text(VectorL10n.ok)))
}
.frame(minHeight: proxy.size.height) // Make the VStack fill the ScrollView's parent
@@ -101,6 +105,15 @@ struct PollEditForm: View {
.font(.headline)
.foregroundColor(theme.colors.primaryContent)
}
ToolbarItem(placement: .navigationBarTrailing) {
if viewModel.viewState.mode == .editing {
Button(VectorL10n.save, action: {
viewModel.send(viewAction: .update)
})
.disabled(!viewModel.viewState.confirmationButtonEnabled)
}
}
}
.navigationBarTitleDisplayMode(.inline)
}
@@ -111,40 +124,6 @@ struct PollEditForm: View {
}
}
@available(iOS 14.0, *)
private struct AnswerOptionGroup: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
@State private var focused = false
@Binding var text: String
let index: Int
let onDelete: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8.0) {
Text(VectorL10n.pollEditFormOptionNumber(index + 1))
.font(theme.fonts.subheadline)
.foregroundColor(theme.colors.primaryContent)
HStack(spacing: 16.0) {
TextField(VectorL10n.pollEditFormInputPlaceholder, text: $text, onEditingChanged: { edit in
self.focused = edit
})
.textFieldStyle(BorderedInputFieldStyle(theme: _theme, isEditing: focused))
Button {
onDelete()
} label: {
Image(uiImage:Asset.Images.pollDeleteOptionIcon.image)
}
.accessibilityIdentifier("Delete answer option")
}
}
}
}
// MARK: - Previews
@available(iOS 14.0, *)

View File

@@ -0,0 +1,63 @@
//
// 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
@available(iOS 14.0, *)
struct PollEditFormAnswerOptionView: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
@State private var focused = false
@Binding var text: String
let index: Int
let onDelete: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8.0) {
Text(VectorL10n.pollEditFormOptionNumber(index + 1))
.font(theme.fonts.subheadline)
.foregroundColor(theme.colors.primaryContent)
HStack(spacing: 16.0) {
TextField(VectorL10n.pollEditFormInputPlaceholder, text: $text, onEditingChanged: { edit in
self.focused = edit
})
.textFieldStyle(BorderedInputFieldStyle(theme: _theme, isEditing: focused))
Button(action: onDelete) {
Image(uiImage:Asset.Images.pollDeleteOptionIcon.image)
}
.accessibilityIdentifier("Delete answer option")
}
}
}
}
@available(iOS 14.0, *)
struct PollEditFormAnswerOptionView_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 32.0) {
PollEditFormAnswerOptionView(text: Binding.constant(""), index: 0) {
}
PollEditFormAnswerOptionView(text: Binding.constant("Test"), index: 5) {
}
}
}
}

View File

@@ -0,0 +1,98 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
@available(iOS 14.0, *)
struct PollEditFormTypePicker: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
@Binding var selectedType: EditFormPollType
var body: some View {
VStack(alignment: .leading, spacing: 16.0) {
Text(VectorL10n.pollEditFormPollType)
.font(theme.fonts.title3SB)
.foregroundColor(theme.colors.primaryContent)
PollEditFormTypeButton(type: .disclosed, selectedType: $selectedType)
PollEditFormTypeButton(type: .undisclosed, selectedType: $selectedType)
}
}
}
@available(iOS 14.0, *)
private struct PollEditFormTypeButton: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
let type: EditFormPollType
@Binding var selectedType: EditFormPollType
var body: some View {
Button {
selectedType = type
} label: {
HStack(alignment: .top, spacing: 8.0) {
Image(uiImage: selectionImage)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
Text(description)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
}
}
}
}
private var title: String {
switch type {
case .disclosed:
return VectorL10n.pollEditFormPollTypeOpen
case .undisclosed:
return VectorL10n.pollEditFormPollTypeClosed
}
}
private var description: String {
switch type {
case .disclosed:
return VectorL10n.pollEditFormPollTypeOpenDescription
case .undisclosed:
return VectorL10n.pollEditFormPollTypeClosedDescription
}
}
private var selectionImage: UIImage {
if type == selectedType {
return Asset.Images.pollTypeCheckboxSelected.image
} else {
return Asset.Images.pollTypeCheckboxDefault.image
}
}
}
@available(iOS 14.0, *)
struct PollEditFormTypePicker_Previews: PreviewProvider {
static var previews: some View {
VStack {
PollEditFormTypePicker(selectedType: Binding.constant(.disclosed))
PollEditFormTypePicker(selectedType: Binding.constant(.undisclosed))
}
}
}

View File

@@ -1,47 +0,0 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI
@available(iOS 14.0, *)
enum MockPollTimelineScreenState: MockScreenState, CaseIterable {
case open
case closed
var screenType: Any.Type {
MockPollTimelineScreenState.self
}
var screenView: ([Any], AnyView) {
let answerOptions = [TimelineAnswerOption(id: "1", text: "First", count: 10, winner: false, selected: false),
TimelineAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true),
TimelineAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false)]
let poll = TimelinePoll(question: "Question",
answerOptions: answerOptions,
closed: (self == .closed ? true : false),
totalAnswerCount: 20,
type: .disclosed,
maxAllowedSelections: 1)
let viewModel = PollTimelineViewModel(timelinePoll: poll)
return ([viewModel], AnyView(PollTimelineView(viewModel: viewModel.context)))
}
}

View File

@@ -1,155 +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
@available(iOS 14.0, *)
struct PollTimelineAnswerOptionButton: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
let answerOption: TimelineAnswerOption
let pollClosed: Bool
let showResults: Bool
let totalAnswerCount: UInt
let action: () -> Void
// MARK: Public
var body: some View {
Button(action: action) {
let rect = RoundedRectangle(cornerRadius: 4.0)
answerOptionLabel
.padding(.horizontal, 8.0)
.padding(.top, 12.0)
.padding(.bottom, 4.0)
.clipShape(rect)
.overlay(rect.stroke(borderAccentColor, lineWidth: 1.0))
.accentColor(progressViewAccentColor)
}
}
var answerOptionLabel: some View {
VStack(alignment: .leading, spacing: 12.0) {
HStack(alignment: .top, spacing: 8.0) {
if !pollClosed {
Image(uiImage: answerOption.selected ? Asset.Images.pollCheckboxSelected.image : Asset.Images.pollCheckboxDefault.image)
}
Text(answerOption.text)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
if pollClosed && answerOption.winner {
Spacer()
Image(uiImage: Asset.Images.pollWinnerIcon.image)
}
}
HStack {
ProgressView(value: Double(showResults ? answerOption.count : 0),
total: Double(totalAnswerCount))
.progressViewStyle(LinearProgressViewStyle())
.scaleEffect(x: 1.0, y: 1.2, anchor: .center)
.padding(.vertical, 8.0)
if (showResults) {
Text(answerOption.count == 1 ? VectorL10n.pollTimelineOneVote : VectorL10n.pollTimelineVotesCount(Int(answerOption.count)))
.font(theme.fonts.footnote)
.foregroundColor(pollClosed && answerOption.winner ? theme.colors.accent : theme.colors.secondaryContent)
}
}
}
}
var borderAccentColor: Color {
guard !pollClosed else {
return (answerOption.winner ? theme.colors.accent : theme.colors.quinaryContent)
}
return answerOption.selected ? theme.colors.accent : theme.colors.quinaryContent
}
var progressViewAccentColor: Color {
guard !pollClosed else {
return (answerOption.winner ? theme.colors.accent : theme.colors.quarterlyContent)
}
return answerOption.selected ? theme.colors.accent : theme.colors.quarterlyContent
}
}
@available(iOS 14.0, *)
struct PollTimelineAnswerOptionButton_Previews: PreviewProvider {
static let stateRenderer = MockPollTimelineScreenState.stateRenderer
static var previews: some View {
Group {
VStack {
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 5, winner: false, selected: false),
pollClosed: false, showResults: true, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 5, winner: false, selected: false),
pollClosed: false, showResults: false, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 8, winner: false, selected: true),
pollClosed: false, showResults: true, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 8, winner: false, selected: true),
pollClosed: false, showResults: false, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "",
text: "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.",
count: 200, winner: false, selected: false),
pollClosed: false, showResults: true, totalAnswerCount: 1000, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "",
text: "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.",
count: 200, winner: false, selected: false),
pollClosed: false, showResults: false, totalAnswerCount: 1000, action: {})
}
VStack {
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 5, winner: false, selected: false),
pollClosed: true, showResults: true, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 5, winner: true, selected: false),
pollClosed: true, showResults: true, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 8, winner: false, selected: true),
pollClosed: true, showResults: true, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 8, winner: true, selected: true),
pollClosed: true, showResults: true, totalAnswerCount: 100, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "",
text: "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.",
count: 200, winner: false, selected: false),
pollClosed: true, showResults: true, totalAnswerCount: 1000, action: {})
PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "",
text: "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.",
count: 200, winner: true, selected: false),
pollClosed: true, showResults: true, totalAnswerCount: 1000, action: {})
}
}
}
}

View File

@@ -1,43 +1,41 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollTimeline PollTimeline
/*
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.
*/
//
// 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
import MatrixSDK
import Combine
struct PollTimelineCoordinatorParameters {
struct TimelinePollCoordinatorParameters {
let session: MXSession
let room: MXRoom
let pollStartEvent: MXEvent
}
@available(iOS 14.0, *)
final class PollTimelineCoordinator: Coordinator, Presentable, PollAggregatorDelegate {
final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDelegate {
// MARK: - Properties
// MARK: Private
private let parameters: PollTimelineCoordinatorParameters
private let parameters: TimelinePollCoordinatorParameters
private let selectedAnswerIdentifiersSubject = PassthroughSubject<[String], Never>()
private var pollAggregator: PollAggregator
private var pollTimelineViewModel: PollTimelineViewModel!
private var viewModel: TimelinePollViewModel!
private var cancellables = Set<AnyCancellable>()
// MARK: Public
@@ -48,14 +46,14 @@ final class PollTimelineCoordinator: Coordinator, Presentable, PollAggregatorDel
// MARK: - Setup
@available(iOS 14.0, *)
init(parameters: PollTimelineCoordinatorParameters) throws {
init(parameters: TimelinePollCoordinatorParameters) throws {
self.parameters = parameters
try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollStartEvent: parameters.pollStartEvent)
try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollStartEventId: parameters.pollStartEvent.eventId)
pollAggregator.delegate = self
pollTimelineViewModel = PollTimelineViewModel(timelinePoll: buildTimelinePollFrom(pollAggregator.poll))
pollTimelineViewModel.callback = { [weak self] result in
viewModel = TimelinePollViewModel(timelinePollDetails: buildTimelinePollFrom(pollAggregator.poll))
viewModel.callback = { [weak self] result in
guard let self = self else { return }
switch result {
@@ -76,9 +74,9 @@ final class PollTimelineCoordinator: Coordinator, Presentable, PollAggregatorDel
localEcho: nil, success: nil) { [weak self] error in
guard let self = self else { return }
MXLog.error("[PollTimelineCoordinator]] Failed submitting response with error \(String(describing: error))")
MXLog.error("[TimelinePollCoordinator]] Failed submitting response with error \(String(describing: error))")
self.pollTimelineViewModel.dispatch(action: .showAnsweringFailure)
self.viewModel.dispatch(action: .showAnsweringFailure)
}
}
.store(in: &cancellables)
@@ -90,23 +88,28 @@ final class PollTimelineCoordinator: Coordinator, Presentable, PollAggregatorDel
}
func toPresentable() -> UIViewController {
return VectorHostingController(rootView: PollTimelineView(viewModel: pollTimelineViewModel.context))
return VectorHostingController(rootView: TimelinePollView(viewModel: viewModel.context))
}
func canEndPoll() -> Bool {
return pollAggregator.poll.isClosed == false
}
func canEditPoll() -> Bool {
return false // Intentionally disabled until platform parity.
// return pollAggregator.poll.isClosed == false && pollAggregator.poll.totalAnswerCount == 0
}
func endPoll() {
parameters.room.sendPollEnd(for: parameters.pollStartEvent, threadId: nil, localEcho: nil, success: nil) { [weak self] error in
self?.pollTimelineViewModel.dispatch(action: .showClosingFailure)
self?.viewModel.dispatch(action: .showClosingFailure)
}
}
// MARK: - PollAggregatorDelegate
func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) {
pollTimelineViewModel.dispatch(action: .updateWithPoll(buildTimelinePollFrom(aggregator.poll)))
viewModel.dispatch(action: .updateWithPoll(buildTimelinePollFrom(aggregator.poll)))
}
func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) {
@@ -125,20 +128,28 @@ final class PollTimelineCoordinator: Coordinator, Presentable, PollAggregatorDel
// PollProtocol is intentionally not available in the SwiftUI target as we don't want
// to add the SDK as a dependency to it. We need to translate from one to the other on this level.
func buildTimelinePollFrom(_ poll: PollProtocol) -> TimelinePoll {
func buildTimelinePollFrom(_ poll: PollProtocol) -> TimelinePollDetails {
let answerOptions = poll.answerOptions.map { pollAnswerOption in
TimelineAnswerOption(id: pollAnswerOption.id,
TimelinePollAnswerOption(id: pollAnswerOption.id,
text: pollAnswerOption.text,
count: pollAnswerOption.count,
winner: pollAnswerOption.isWinner,
selected: pollAnswerOption.isCurrentUserSelection)
}
return TimelinePoll(question: poll.text,
return TimelinePollDetails(question: poll.text,
answerOptions: answerOptions,
closed: poll.isClosed,
totalAnswerCount: poll.totalAnswerCount,
type: (poll.kind == .disclosed ? .disclosed : .undisclosed),
maxAllowedSelections: poll.maxAllowedSelections)
type: pollKindToTimelinePollType(poll.kind),
maxAllowedSelections: poll.maxAllowedSelections,
hasBeenEdited: poll.hasBeenEdited)
}
private func pollKindToTimelinePollType(_ kind: PollKind) -> TimelinePollType {
let mapping = [PollKind.disclosed: TimelinePollType.disclosed,
PollKind.undisclosed: TimelinePollType.undisclosed]
return mapping[kind] ?? .disclosed
}
}

View File

@@ -17,11 +17,11 @@
import Foundation
@available(iOS 14, *)
class PollTimelineProvider {
static let shared = PollTimelineProvider()
class TimelinePollProvider {
static let shared = TimelinePollProvider()
var session: MXSession?
var coordinatorsForEventIdentifiers = [String: PollTimelineCoordinator]()
var coordinatorsForEventIdentifiers = [String: TimelinePollCoordinator]()
private init() {
@@ -29,7 +29,7 @@ class PollTimelineProvider {
/// Create or retrieve the poll timeline coordinator for this event and return
/// a view to be displayed in the timeline
func buildPollTimelineViewForEvent(_ event: MXEvent) -> UIView? {
func buildTimelinePollViewForEvent(_ event: MXEvent) -> UIView? {
guard let session = session, let room = session.room(withRoomId: event.roomId) else {
return nil
}
@@ -38,8 +38,8 @@ class PollTimelineProvider {
return coordinator.toPresentable().view
}
let parameters = PollTimelineCoordinatorParameters(session: session, room: room, pollStartEvent: event)
guard let coordinator = try? PollTimelineCoordinator(parameters: parameters) else {
let parameters = TimelinePollCoordinatorParameters(session: session, room: room, pollStartEvent: event)
guard let coordinator = try? TimelinePollCoordinator(parameters: parameters) else {
return nil
}
@@ -49,7 +49,7 @@ class PollTimelineProvider {
}
/// Retrieve the poll timeline coordinator for the given event or nil if it hasn't been created yet
func pollTimelineCoordinatorForEventIdentifier(_ eventIdentifier: String) -> PollTimelineCoordinator? {
func timelinePollCoordinatorForEventIdentifier(_ eventIdentifier: String) -> TimelinePollCoordinator? {
return coordinatorsForEventIdentifiers[eventIdentifier]
}
}

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollTimeline PollTimeline
//
// Copyright 2021 New Vector Ltd
//
@@ -20,7 +18,7 @@ import XCTest
import RiotSwiftUI
@available(iOS 14.0, *)
class PollTimelineUITests: XCTestCase {
class TimelinePollUITests: XCTestCase {
private var app: XCUIApplication!
@@ -31,8 +29,8 @@ class PollTimelineUITests: XCTestCase {
app.launch()
}
func testOpenPoll() {
app.goToScreenWithIdentifier(MockPollTimelineScreenState.open.title)
func testOpenDisclosedPoll() {
app.goToScreenWithIdentifier(MockTimelinePollScreenState.openDisclosed.title)
XCTAssert(app.staticTexts["Question"].exists)
XCTAssert(app.staticTexts["20 votes cast"].exists)
@@ -69,9 +67,48 @@ class PollTimelineUITests: XCTestCase {
XCTAssertEqual(app.buttons["Third, 16 votes"].value as! String, "80%")
}
func testClosedPoll() {
app.goToScreenWithIdentifier(MockPollTimelineScreenState.closed.title)
func testOpenUndisclosedPoll() {
app.goToScreenWithIdentifier(MockTimelinePollScreenState.openUndisclosed.title)
XCTAssert(app.staticTexts["Question"].exists)
XCTAssert(app.staticTexts["20 votes cast"].exists)
XCTAssert(!app.buttons["First, 10 votes"].exists)
XCTAssert(app.buttons["First"].exists)
XCTAssertTrue((app.buttons["First"].value as! String).isEmpty)
XCTAssert(!app.buttons["Second, 5 votes"].exists)
XCTAssert(app.buttons["Second"].exists)
XCTAssertTrue((app.buttons["Second"].value as! String).isEmpty)
XCTAssert(!app.buttons["Third, 15 votes"].exists)
XCTAssert(app.buttons["Third"].exists)
XCTAssertTrue((app.buttons["Third"].value as! String).isEmpty)
app.buttons["First"].tap()
XCTAssert(app.buttons["First"].exists)
XCTAssert(app.buttons["Second"].exists)
XCTAssert(app.buttons["Third"].exists)
app.buttons["Third"].tap()
XCTAssert(app.buttons["First"].exists)
XCTAssert(app.buttons["Second"].exists)
XCTAssert(app.buttons["Third"].exists)
}
func testClosedDisclosedPoll() {
app.goToScreenWithIdentifier(MockTimelinePollScreenState.closedDisclosed.title)
checkClosedPoll()
}
func testClosedUndisclosedPoll() {
app.goToScreenWithIdentifier(MockTimelinePollScreenState.closedUndisclosed.title)
checkClosedPoll()
}
private func checkClosedPoll() {
XCTAssert(app.staticTexts["Question"].exists)
XCTAssert(app.staticTexts["Final results based on 20 votes"].exists)

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollTimeline PollTimeline
//
// Copyright 2021 New Vector Ltd
//
@@ -22,24 +20,25 @@ import Combine
@testable import RiotSwiftUI
@available(iOS 14.0, *)
class PollTimelineViewModelTests: XCTestCase {
var viewModel: PollTimelineViewModel!
var context: PollTimelineViewModelType.Context!
class TimelinePollViewModelTests: XCTestCase {
var viewModel: TimelinePollViewModel!
var context: TimelinePollViewModelType.Context!
var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
let answerOptions = [TimelineAnswerOption(id: "1", text: "1", count: 1, winner: false, selected: false),
TimelineAnswerOption(id: "2", text: "2", count: 1, winner: false, selected: false),
TimelineAnswerOption(id: "3", text: "3", count: 1, winner: false, selected: false)]
let answerOptions = [TimelinePollAnswerOption(id: "1", text: "1", count: 1, winner: false, selected: false),
TimelinePollAnswerOption(id: "2", text: "2", count: 1, winner: false, selected: false),
TimelinePollAnswerOption(id: "3", text: "3", count: 1, winner: false, selected: false)]
let timelinePoll = TimelinePoll(question: "Question",
answerOptions: answerOptions,
closed: false,
totalAnswerCount: 3,
type: .disclosed,
maxAllowedSelections: 1)
let timelinePoll = TimelinePollDetails(question: "Question",
answerOptions: answerOptions,
closed: false,
totalAnswerCount: 3,
type: .disclosed,
maxAllowedSelections: 1,
hasBeenEdited: false)
viewModel = PollTimelineViewModel(timelinePoll: timelinePoll)
viewModel = TimelinePollViewModel(timelinePollDetails: timelinePoll)
context = viewModel.context
}

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollTimeline PollTimeline
//
// Copyright 2021 New Vector Ltd
//
@@ -19,20 +17,20 @@
import Foundation
import SwiftUI
typealias PollTimelineViewModelCallback = ((PollTimelineViewModelResult) -> Void)
typealias TimelinePollViewModelCallback = ((TimelinePollViewModelResult) -> Void)
enum PollTimelineStateAction {
case viewAction(PollTimelineViewAction, PollTimelineViewModelCallback?)
case updateWithPoll(TimelinePoll)
enum TimelinePollStateAction {
case viewAction(TimelinePollViewAction, TimelinePollViewModelCallback?)
case updateWithPoll(TimelinePollDetails)
case showAnsweringFailure
case showClosingFailure
}
enum PollTimelineViewAction {
enum TimelinePollViewAction {
case selectAnswerOptionWithIdentifier(String)
}
enum PollTimelineViewModelResult {
enum TimelinePollViewModelResult {
case selectedAnswerOptionsWithIdentifiers([String])
}
@@ -41,7 +39,7 @@ enum TimelinePollType {
case undisclosed
}
class TimelineAnswerOption: Identifiable {
class TimelinePollAnswerOption: Identifiable {
var id: String
var text: String
var count: UInt
@@ -57,35 +55,59 @@ class TimelineAnswerOption: Identifiable {
}
}
class TimelinePoll {
class TimelinePollDetails {
var question: String
var answerOptions: [TimelineAnswerOption]
var answerOptions: [TimelinePollAnswerOption]
var closed: Bool
var totalAnswerCount: UInt
var type: TimelinePollType
var maxAllowedSelections: UInt
var hasBeenEdited: Bool = true
init(question: String, answerOptions: [TimelineAnswerOption], closed: Bool, totalAnswerCount: UInt, type: TimelinePollType, maxAllowedSelections: UInt) {
init(question: String, answerOptions: [TimelinePollAnswerOption],
closed: Bool,
totalAnswerCount: UInt,
type: TimelinePollType,
maxAllowedSelections: UInt,
hasBeenEdited: Bool) {
self.question = question
self.answerOptions = answerOptions
self.closed = closed
self.totalAnswerCount = totalAnswerCount
self.type = type
self.maxAllowedSelections = maxAllowedSelections
self.hasBeenEdited = hasBeenEdited
}
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
}
}
}
struct PollTimelineViewState: BindableState {
var poll: TimelinePoll
var bindings: PollTimelineViewStateBindings
struct TimelinePollViewState: BindableState {
var poll: TimelinePollDetails
var bindings: TimelinePollViewStateBindings
}
struct PollTimelineViewStateBindings {
var showsAnsweringFailureAlert: Bool = false
var showsClosingFailureAlert: Bool = false
struct TimelinePollViewStateBindings {
var alertInfo: TimelinePollErrorAlertInfo?
}
struct TimelinePollErrorAlertInfo: Identifiable {
enum AlertType {
case failedClosingPoll
case failedSubmittingAnswer
}
let id: AlertType
let title: String
let subtitle: String
}

View File

@@ -0,0 +1,48 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI
@available(iOS 14.0, *)
enum MockTimelinePollScreenState: MockScreenState, CaseIterable {
case openDisclosed
case closedDisclosed
case openUndisclosed
case closedUndisclosed
var screenType: Any.Type {
TimelinePollDetails.self
}
var screenView: ([Any], AnyView) {
let answerOptions = [TimelinePollAnswerOption(id: "1", text: "First", count: 10, winner: false, selected: false),
TimelinePollAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true),
TimelinePollAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false)]
let poll = TimelinePollDetails(question: "Question",
answerOptions: answerOptions,
closed: (self == .closedDisclosed || self == .closedUndisclosed ? true : false),
totalAnswerCount: 20,
type: (self == .closedDisclosed || self == .openDisclosed ? .disclosed : .undisclosed),
maxAllowedSelections: 1,
hasBeenEdited: false)
let viewModel = TimelinePollViewModel(timelinePollDetails: poll)
return ([viewModel], AnyView(TimelinePollView(viewModel: viewModel.context)))
}
}

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollTimeline PollTimeline
//
// Copyright 2021 New Vector Ltd
//
@@ -20,11 +18,11 @@ import SwiftUI
import Combine
@available(iOS 14, *)
typealias PollTimelineViewModelType = StateStoreViewModel<PollTimelineViewState,
PollTimelineStateAction,
PollTimelineViewAction>
typealias TimelinePollViewModelType = StateStoreViewModel<TimelinePollViewState,
TimelinePollStateAction,
TimelinePollViewAction>
@available(iOS 14, *)
class PollTimelineViewModel: PollTimelineViewModelType {
class TimelinePollViewModel: TimelinePollViewModelType {
// MARK: - Properties
@@ -32,24 +30,24 @@ class PollTimelineViewModel: PollTimelineViewModelType {
// MARK: Public
var callback: PollTimelineViewModelCallback?
var callback: TimelinePollViewModelCallback?
// MARK: - Setup
init(timelinePoll: TimelinePoll) {
super.init(initialViewState: PollTimelineViewState(poll: timelinePoll, bindings: PollTimelineViewStateBindings()))
init(timelinePollDetails: TimelinePollDetails) {
super.init(initialViewState: TimelinePollViewState(poll: timelinePollDetails, bindings: TimelinePollViewStateBindings()))
}
// MARK: - Public
override func process(viewAction: PollTimelineViewAction) {
override func process(viewAction: TimelinePollViewAction) {
switch viewAction {
case .selectAnswerOptionWithIdentifier(_):
dispatch(action: .viewAction(viewAction, callback))
}
}
override class func reducer(state: inout PollTimelineViewState, action: PollTimelineStateAction) {
override class func reducer(state: inout TimelinePollViewState, action: TimelinePollStateAction) {
switch action {
case .viewAction(let viewAction, let callback):
switch viewAction {
@@ -69,15 +67,19 @@ class PollTimelineViewModel: PollTimelineViewModelType {
case .updateWithPoll(let poll):
state.poll = poll
case .showAnsweringFailure:
state.bindings.showsAnsweringFailureAlert = true
state.bindings.alertInfo = TimelinePollErrorAlertInfo(id: .failedSubmittingAnswer,
title: VectorL10n.pollTimelineVoteNotRegisteredTitle,
subtitle: VectorL10n.pollTimelineVoteNotRegisteredSubtitle)
case .showClosingFailure:
state.bindings.showsClosingFailureAlert = true
state.bindings.alertInfo = TimelinePollErrorAlertInfo(id: .failedClosingPoll,
title: VectorL10n.pollTimelineNotClosedTitle,
subtitle: VectorL10n.pollTimelineNotClosedSubtitle)
}
}
// MARK: - Private
static func updateSingleSelectPollLocalState(_ state: inout PollTimelineViewState, selectedAnswerIdentifier: String, callback: PollTimelineViewModelCallback?) {
static func updateSingleSelectPollLocalState(_ state: inout TimelinePollViewState, selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) {
for answerOption in state.poll.answerOptions {
if answerOption.selected {
answerOption.selected = false
@@ -98,7 +100,7 @@ class PollTimelineViewModel: PollTimelineViewModelType {
informCoordinatorOfSelectionUpdate(state: state, callback: callback)
}
static func updateMultiSelectPollLocalState(_ state: inout PollTimelineViewState, selectedAnswerIdentifier: String, callback: PollTimelineViewModelCallback?) {
static func updateMultiSelectPollLocalState(_ state: inout TimelinePollViewState, selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) {
let selectedAnswerOptions = state.poll.answerOptions.filter { $0.selected == true }
let isDeselecting = selectedAnswerOptions.filter { $0.id == selectedAnswerIdentifier }.count > 0
@@ -122,7 +124,7 @@ class PollTimelineViewModel: PollTimelineViewModelType {
informCoordinatorOfSelectionUpdate(state: state, callback: callback)
}
static func informCoordinatorOfSelectionUpdate(state: PollTimelineViewState, callback: PollTimelineViewModelCallback?) {
static func informCoordinatorOfSelectionUpdate(state: TimelinePollViewState, callback: TimelinePollViewModelCallback?) {
let selectedIdentifiers = state.poll.answerOptions.compactMap { answerOption in
answerOption.selected ? answerOption.id : nil
}

View File

@@ -0,0 +1,157 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
@available(iOS 14.0, *)
struct TimelinePollAnswerOptionButton: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
let poll: TimelinePollDetails
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)
}
}
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)
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)
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)
}
}
}
}
}
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
}
}
@available(iOS 14.0, *)
struct TimelinePollAnswerOptionButton_Previews: PreviewProvider {
static let stateRenderer = MockTimelinePollScreenState.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,
maxAllowedSelections: 1,
hasBeenEdited: false)
}
static func buildAnswerOption(text: String = "Test", selected: Bool, winner: Bool = false) -> TimelinePollAnswerOption {
TimelinePollAnswerOption(id: "1", text: text, count: 5, winner: winner, selected: selected)
}
}

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/PollEditForm PollEditForm
//
// Copyright 2021 New Vector Ltd
//
@@ -19,7 +17,7 @@
import SwiftUI
@available(iOS 14.0, *)
struct PollTimelineView: View {
struct TimelinePollView: View {
// MARK: - Properties
@@ -29,29 +27,26 @@ struct PollTimelineView: View {
// MARK: Public
@ObservedObject var viewModel: PollTimelineViewModel.Context
@ObservedObject var viewModel: TimelinePollViewModel.Context
var body: some View {
let poll = viewModel.viewState.poll
VStack(alignment: .leading, spacing: 16.0) {
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
PollTimelineAnswerOptionButton(answerOption: answerOption,
pollClosed: poll.closed,
showResults: shouldDiscloseResults,
totalAnswerCount: poll.totalAnswerCount) {
TimelinePollAnswerOptionButton(poll: poll, answerOption: answerOption) {
viewModel.send(viewAction: .selectAnswerOptionWithIdentifier(answerOption.id))
}
}
.alert(isPresented: $viewModel.showsClosingFailureAlert) {
Alert(title: Text(VectorL10n.pollTimelineNotClosedTitle),
message: Text(VectorL10n.pollTimelineNotClosedSubtitle),
dismissButton: .default(Text(VectorL10n.ok)))
}
}
.disabled(poll.closed)
.fixedSize(horizontal: false, vertical: true)
@@ -59,14 +54,14 @@ struct PollTimelineView: View {
Text(totalVotesString)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.tertiaryContent)
.alert(isPresented: $viewModel.showsAnsweringFailureAlert) {
Alert(title: Text(VectorL10n.pollTimelineVoteNotRegisteredTitle),
message: Text(VectorL10n.pollTimelineVoteNotRegisteredSubtitle),
dismissButton: .default(Text(VectorL10n.ok)))
}
}
.padding([.horizontal, .top], 2.0)
.padding([.bottom])
.alert(item: $viewModel.alertInfo) { info in
Alert(title: Text(info.title),
message: Text(info.subtitle),
dismissButton: .default(Text(VectorL10n.ok)))
}
}
private var totalVotesString: String {
@@ -84,32 +79,26 @@ struct PollTimelineView: View {
case 0:
return VectorL10n.pollTimelineTotalNoVotes
case 1:
return (poll.hasCurrentUserVoted ?
return (poll.hasCurrentUserVoted || poll.type == .undisclosed ?
VectorL10n.pollTimelineTotalOneVote :
VectorL10n.pollTimelineTotalOneVoteNotVoted)
default:
return (poll.hasCurrentUserVoted ?
return (poll.hasCurrentUserVoted || poll.type == .undisclosed ?
VectorL10n.pollTimelineTotalVotes(Int(poll.totalAnswerCount)) :
VectorL10n.pollTimelineTotalVotesNotVoted(Int(poll.totalAnswerCount)))
}
}
private var shouldDiscloseResults: Bool {
let poll = viewModel.viewState.poll
if poll.closed {
return poll.totalAnswerCount > 0
} else {
return poll.type == .disclosed && poll.totalAnswerCount > 0 && poll.hasCurrentUserVoted
}
private var editedText: String {
viewModel.viewState.poll.hasBeenEdited ? " \(VectorL10n.eventFormatterMessageEditedMention)" : ""
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct PollTimelineView_Previews: PreviewProvider {
static let stateRenderer = MockPollTimelineScreenState.stateRenderer
struct TimelinePollView_Previews: PreviewProvider {
static let stateRenderer = MockTimelinePollScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup()
}

View File

@@ -1,20 +1,18 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
/*
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.
*/
//
// 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

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//
@@ -26,7 +24,7 @@ enum MockUserSuggestionScreenState: MockScreenState, CaseIterable {
static private var members: [RoomMembersProviderMember]!
var screenType: Any.Type {
MockUserSuggestionScreenState.self
UserSuggestionList.self
}
var screenView: ([Any], AnyView) {

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,5 +1,3 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Room/UserSuggestion UserSuggestion
//
// Copyright 2021 New Vector Ltd
//

View File

@@ -1,20 +1,18 @@
// File created from ScreenTemplate
// $ createScreen.sh Settings/Notifications NotificationSettings
/*
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.
*/
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI

View File

@@ -1,20 +1,18 @@
// File created from FlowTemplate
// $ createRootCoordinator.sh TemplateRoomsCoordinator TemplateRooms
/*
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.
*/
//
// 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 UIKit

View File

@@ -1,20 +1,18 @@
// File created from FlowTemplate
// $ createRootCoordinator.sh TemplateRoomsCoordinator TemplateRooms
/*
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.
*/
//
// 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

View File

@@ -35,7 +35,7 @@ struct TemplateRoomListRow: View {
AvatarImage(avatarData: avatar, size: .medium)
Text(displayName ?? "")
.foregroundColor(theme.colors.primaryContent)
.accessibility(identifier: "roomNameText")
.accessibility(identifier: "roomNameText")
Spacer()
}
//add to a style