mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-04-24 18:42:47 +02:00
Merge branch gil/5230_SP2-Adding_Rooms_to_Spaces into gil/5231_SP3-1_Update_room_settings_for_Spaces
This commit is contained in:
+14
-26
@@ -17,7 +17,6 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import Keys
|
||||
|
||||
struct LocationSharingCoordinatorParameters {
|
||||
let roomDataSource: MXKRoomDataSource
|
||||
@@ -34,12 +33,7 @@ final class LocationSharingCoordinator: Coordinator, Presentable {
|
||||
|
||||
private let parameters: LocationSharingCoordinatorParameters
|
||||
private let locationSharingHostingController: UIViewController
|
||||
private var _locationSharingViewModel: Any? = nil
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
fileprivate var locationSharingViewModel: LocationSharingViewModel {
|
||||
return _locationSharingViewModel as! LocationSharingViewModel
|
||||
}
|
||||
private var locationSharingViewModel: LocationSharingViewModelProtocol
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@@ -53,13 +47,13 @@ final class LocationSharingCoordinator: Coordinator, Presentable {
|
||||
init(parameters: LocationSharingCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = LocationSharingViewModel(tileServerMapURL: BuildSettings.tileServerMapURL,
|
||||
let viewModel = LocationSharingViewModel(mapStyleURL: BuildSettings.tileServerMapStyleURL,
|
||||
avatarData: parameters.avatarData,
|
||||
location: parameters.location)
|
||||
let view = LocationSharingView(context: viewModel.context)
|
||||
.addDependency(AvatarService.instantiate(mediaManager: parameters.mediaManager))
|
||||
|
||||
_locationSharingViewModel = viewModel
|
||||
locationSharingViewModel = viewModel
|
||||
locationSharingHostingController = VectorHostingController(rootView: view)
|
||||
}
|
||||
|
||||
@@ -78,43 +72,37 @@ final class LocationSharingCoordinator: Coordinator, Presentable {
|
||||
self.completion?()
|
||||
case .share(let latitude, let longitude):
|
||||
if let location = self.parameters.location {
|
||||
self.showActivityControllerForLocation(location)
|
||||
self.locationSharingHostingController.present(Self.shareLocationActivityController(location), animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
self.locationSharingViewModel.dispatch(action: .startLoading)
|
||||
self.locationSharingViewModel.startLoading()
|
||||
|
||||
self.parameters.roomDataSource.sendLocation(withLatitude: latitude,
|
||||
longitude: longitude,
|
||||
description: nil) { [weak self] _ in
|
||||
self.parameters.roomDataSource.sendLocation(withLatitude: latitude, longitude: longitude, description: nil) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.locationSharingViewModel.dispatch(action: .stopLoading(nil))
|
||||
self.locationSharingViewModel.stopLoading()
|
||||
self.completion?()
|
||||
} failure: { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
|
||||
MXLog.error("[LocationSharingCoordinator] Failed sharing location with error: \(String(describing: error))")
|
||||
self.locationSharingViewModel.dispatch(action: .stopLoading(error))
|
||||
self.locationSharingViewModel.stopLoading(error: .locationSharingError)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
static func shareLocationActivityController(_ location: CLLocationCoordinate2D) -> UIActivityViewController {
|
||||
return UIActivityViewController(activityItems: [ShareToMapsAppActivity.urlForMapsAppType(.apple, location: location)],
|
||||
applicationActivities: [ShareToMapsAppActivity(type: .apple, location: location),
|
||||
ShareToMapsAppActivity(type: .google, location: location)])
|
||||
}
|
||||
|
||||
// MARK: - Presentable
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
return locationSharingHostingController
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func showActivityControllerForLocation(_ location: CLLocationCoordinate2D) {
|
||||
let vc = UIActivityViewController(activityItems: [ShareToMapsAppActivity.urlForMapsAppType(.apple, location: location)],
|
||||
applicationActivities: [ShareToMapsAppActivity(type: .apple, location: location),
|
||||
ShareToMapsAppActivity(type: .google, location: location)])
|
||||
|
||||
locationSharingHostingController.present(vc, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ class ShareToMapsAppActivity: UIActivity {
|
||||
case google
|
||||
}
|
||||
|
||||
let type: MapsAppType
|
||||
let location: CLLocationCoordinate2D
|
||||
private let type: MapsAppType
|
||||
private let location: CLLocationCoordinate2D
|
||||
|
||||
private override init() {
|
||||
fatalError()
|
||||
|
||||
@@ -19,6 +19,16 @@ import SwiftUI
|
||||
import Combine
|
||||
import CoreLocation
|
||||
|
||||
enum LocationSharingViewAction {
|
||||
case cancel
|
||||
case share
|
||||
}
|
||||
|
||||
enum LocationSharingViewModelResult {
|
||||
case cancel
|
||||
case share(latitude: Double, longitude: Double)
|
||||
}
|
||||
|
||||
enum LocationSharingViewError {
|
||||
case failedLoadingMap
|
||||
case failedLocatingUser
|
||||
@@ -26,27 +36,9 @@ enum LocationSharingViewError {
|
||||
case failedSharingLocation
|
||||
}
|
||||
|
||||
enum LocationSharingStateAction {
|
||||
case error(LocationSharingViewError, LocationSharingViewModelCallback?)
|
||||
case startLoading
|
||||
case stopLoading(Error?)
|
||||
}
|
||||
|
||||
enum LocationSharingViewAction {
|
||||
case cancel
|
||||
case share
|
||||
}
|
||||
|
||||
typealias LocationSharingViewModelCallback = ((LocationSharingViewModelResult) -> Void)
|
||||
|
||||
enum LocationSharingViewModelResult {
|
||||
case cancel
|
||||
case share(latitude: Double, longitude: Double)
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
struct LocationSharingViewState: BindableState {
|
||||
let tileServerMapURL: URL
|
||||
let mapStyleURL: URL
|
||||
let avatarData: AvatarInputProtocol
|
||||
let location: CLLocationCoordinate2D?
|
||||
|
||||
@@ -66,11 +58,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
|
||||
@@ -80,6 +72,7 @@ struct ErrorAlertInfo: Identifiable {
|
||||
|
||||
let id: AlertType
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
let primaryButton: (title: String, action: (() -> Void)?)
|
||||
let secondaryButton: (title: String, action: (() -> Void)?)?
|
||||
var secondaryButton: (title: String, action: (() -> Void)?)? = nil
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Keys
|
||||
import CoreLocation
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
@@ -25,7 +24,7 @@ enum MockLocationSharingScreenState: MockScreenState, CaseIterable {
|
||||
case displayExistingLocation
|
||||
|
||||
var screenType: Any.Type {
|
||||
MockLocationSharingScreenState.self
|
||||
LocationSharingView.self
|
||||
}
|
||||
|
||||
var screenView: ([Any], AnyView) {
|
||||
@@ -35,8 +34,8 @@ enum MockLocationSharingScreenState: MockScreenState, CaseIterable {
|
||||
location = CLLocationCoordinate2D(latitude: 51.4932641, longitude: -0.257096)
|
||||
}
|
||||
|
||||
let mapURL = URL(string: "https://api.maptiler.com/maps/streets/style.json?key=" + RiotKeys().mapTilerAPIKey)!
|
||||
let viewModel = LocationSharingViewModel(tileServerMapURL: mapURL,
|
||||
let mapStyleURL = URL(string: "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx")!
|
||||
let viewModel = LocationSharingViewModel(mapStyleURL: mapStyleURL,
|
||||
avatarData: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: "Alice"),
|
||||
location: location)
|
||||
return ([viewModel],
|
||||
|
||||
@@ -19,11 +19,11 @@ import Combine
|
||||
import CoreLocation
|
||||
|
||||
@available(iOS 14, *)
|
||||
typealias LocationSharingViewModelType = StateStoreViewModel< LocationSharingViewState,
|
||||
LocationSharingStateAction,
|
||||
LocationSharingViewAction >
|
||||
typealias LocationSharingViewModelType = StateStoreViewModel<LocationSharingViewState,
|
||||
Never,
|
||||
LocationSharingViewAction>
|
||||
@available(iOS 14, *)
|
||||
class LocationSharingViewModel: LocationSharingViewModelType {
|
||||
class LocationSharingViewModel: LocationSharingViewModelType, LocationSharingViewModelProtocol {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
@@ -35,13 +35,13 @@ class LocationSharingViewModel: LocationSharingViewModelType {
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(tileServerMapURL: URL, avatarData: AvatarInputProtocol, location: CLLocationCoordinate2D? = nil) {
|
||||
let viewState = LocationSharingViewState(tileServerMapURL: tileServerMapURL, avatarData: avatarData, location: location)
|
||||
init(mapStyleURL: URL, avatarData: AvatarInputProtocol, location: CLLocationCoordinate2D? = nil) {
|
||||
let viewState = LocationSharingViewState(mapStyleURL: mapStyleURL, avatarData: avatarData, location: location)
|
||||
super.init(initialViewState: viewState)
|
||||
|
||||
state.errorSubject.sink { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
self.dispatch(action: .error(error, self.completion))
|
||||
self.processError(error)
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ class LocationSharingViewModel: LocationSharingViewModelType {
|
||||
}
|
||||
|
||||
guard let location = state.bindings.userLocation else {
|
||||
dispatch(action: .error(.failedLocatingUser, completion))
|
||||
processError(.failedLocatingUser)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,45 +66,54 @@ class LocationSharingViewModel: LocationSharingViewModelType {
|
||||
}
|
||||
}
|
||||
|
||||
override class func reducer(state: inout LocationSharingViewState, action: LocationSharingStateAction) {
|
||||
switch action {
|
||||
case .error(let error, let completion):
|
||||
|
||||
switch error {
|
||||
case .failedLoadingMap:
|
||||
state.bindings.alertInfo = ErrorAlertInfo(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)
|
||||
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)
|
||||
}
|
||||
}))
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
case .startLoading:
|
||||
state.showLoadingIndicator = true
|
||||
case .stopLoading(let error):
|
||||
state.showLoadingIndicator = false
|
||||
|
||||
if error != nil {
|
||||
state.bindings.alertInfo = ErrorAlertInfo(id: .locationSharingError,
|
||||
title: VectorL10n.locationSharingInvalidAuthorizationErrorTitle(AppInfo.current.displayName),
|
||||
primaryButton: (VectorL10n.ok, nil),
|
||||
secondaryButton: nil)
|
||||
}
|
||||
// MARK: - LocationSharingViewModelProtocol
|
||||
|
||||
public func startLoading() {
|
||||
state.showLoadingIndicator = true
|
||||
}
|
||||
|
||||
func stopLoading(error: LocationSharingErrorAlertInfo.AlertType?) {
|
||||
state.showLoadingIndicator = false
|
||||
|
||||
if let error = error {
|
||||
state.bindings.alertInfo = LocationSharingErrorAlertInfo(id: error,
|
||||
title: VectorL10n.locationSharingPostFailureTitle,
|
||||
subtitle: VectorL10n.locationSharingPostFailureSubtitle(AppInfo.current.displayName),
|
||||
primaryButton: (VectorL10n.ok, nil))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func processError(_ error: LocationSharingViewError) {
|
||||
guard state.bindings.alertInfo == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
let primaryButtonCompletion = { [weak self] () -> Void in
|
||||
self?.completion?(.cancel)
|
||||
}
|
||||
|
||||
switch error {
|
||||
case .failedLoadingMap:
|
||||
state.bindings.alertInfo = LocationSharingErrorAlertInfo(id: .mapLoadingError,
|
||||
title: VectorL10n.locationSharingLoadingMapErrorTitle(AppInfo.current.displayName),
|
||||
primaryButton: (VectorL10n.ok, primaryButtonCompletion))
|
||||
case .failedLocatingUser:
|
||||
state.bindings.alertInfo = LocationSharingErrorAlertInfo(id: .userLocatingError,
|
||||
title: VectorL10n.locationSharingLocatingUserErrorTitle(AppInfo.current.displayName),
|
||||
primaryButton: (VectorL10n.ok, primaryButtonCompletion))
|
||||
case .invalidLocationAuthorization:
|
||||
state.bindings.alertInfo = LocationSharingErrorAlertInfo(id: .authorizationError,
|
||||
title: VectorL10n.locationSharingInvalidAuthorizationErrorTitle(AppInfo.current.displayName),
|
||||
primaryButton: (VectorL10n.locationSharingInvalidAuthorizationNotNow, primaryButtonCompletion),
|
||||
secondaryButton: (VectorL10n.locationSharingInvalidAuthorizationSettings, {
|
||||
if let applicationSettingsURL = URL(string:UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(applicationSettingsURL)
|
||||
}
|
||||
}))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-5
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/UserSuggestion UserSuggestion
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
@@ -18,7 +16,15 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
enum UserSuggestionStateAction {
|
||||
case updateWithItems([UserSuggestionItemProtocol])
|
||||
protocol LocationSharingViewModelProtocol {
|
||||
var completion: ((LocationSharingViewModelResult) -> Void)? { get set }
|
||||
|
||||
func startLoading()
|
||||
func stopLoading(error: LocationSharingErrorAlertInfo.AlertType?)
|
||||
}
|
||||
|
||||
extension LocationSharingViewModelProtocol {
|
||||
func stopLoading() {
|
||||
stopLoading(error: nil)
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ class LocationSharingUITests: XCTestCase {
|
||||
goToScreenWithIdentifier(MockLocationSharingScreenState.displayExistingLocation.title)
|
||||
|
||||
XCTAssertTrue(app.buttons["Cancel"].exists)
|
||||
XCTAssertTrue(app.buttons["location share icon"].exists)
|
||||
XCTAssertTrue(app.buttons["LocationSharingView.shareButton"].exists)
|
||||
XCTAssertTrue(app.otherElements["Map"].exists)
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -32,7 +32,7 @@ class LocationSharingViewModelTests: XCTestCase {
|
||||
XCTAssertTrue(viewModel.context.viewState.shareButtonVisible)
|
||||
XCTAssertFalse(viewModel.context.viewState.showLoadingIndicator)
|
||||
|
||||
XCTAssertNotNil(viewModel.context.viewState.tileServerMapURL)
|
||||
XCTAssertNotNil(viewModel.context.viewState.mapStyleURL)
|
||||
XCTAssertNotNil(viewModel.context.viewState.avatarData)
|
||||
|
||||
XCTAssertNil(viewModel.context.viewState.location)
|
||||
@@ -100,12 +100,12 @@ class LocationSharingViewModelTests: XCTestCase {
|
||||
func testLoading() {
|
||||
let viewModel = buildViewModel(withLocation: false)
|
||||
|
||||
viewModel.dispatch(action: .startLoading)
|
||||
viewModel.startLoading()
|
||||
|
||||
XCTAssertFalse(viewModel.context.viewState.shareButtonEnabled)
|
||||
XCTAssertTrue(viewModel.context.viewState.showLoadingIndicator)
|
||||
|
||||
viewModel.dispatch(action: .stopLoading(nil))
|
||||
viewModel.stopLoading()
|
||||
|
||||
XCTAssertTrue(viewModel.context.viewState.shareButtonEnabled)
|
||||
XCTAssertFalse(viewModel.context.viewState.showLoadingIndicator)
|
||||
@@ -121,8 +121,8 @@ class LocationSharingViewModelTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func buildViewModel(withLocation: Bool) -> LocationSharingViewModel {
|
||||
LocationSharingViewModel(tileServerMapURL: URL(string: "http://empty.com")!,
|
||||
avatarData: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: ""),
|
||||
location: (withLocation ? CLLocationCoordinate2D(latitude: 51.4932641, longitude: -0.257096) : nil))
|
||||
LocationSharingViewModel(mapStyleURL: URL(string: "http://empty.com")!,
|
||||
avatarData: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: ""),
|
||||
location: (withLocation ? CLLocationCoordinate2D(latitude: 51.4932641, longitude: -0.257096) : nil))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ class LocationSharingMapViewCoordinator: NSObject, MGLMapViewDelegate {
|
||||
|
||||
private let avatarData: AvatarInputProtocol
|
||||
private let errorSubject: PassthroughSubject<LocationSharingViewError, Never>
|
||||
@Binding var userLocation: CLLocationCoordinate2D?
|
||||
@Binding private var userLocation: CLLocationCoordinate2D?
|
||||
|
||||
init(avatarData: AvatarInputProtocol,
|
||||
errorSubject: PassthroughSubject<LocationSharingViewError, Never>,
|
||||
@@ -89,6 +89,10 @@ class LocationSharingMapViewCoordinator: NSObject, MGLMapViewDelegate {
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MGLMapView, didFailToLocateUserWithError error: Error) {
|
||||
guard mapView.showsUserLocation else {
|
||||
return
|
||||
}
|
||||
|
||||
errorSubject.send(.failedLocatingUser)
|
||||
}
|
||||
|
||||
@@ -97,11 +101,15 @@ class LocationSharingMapViewCoordinator: NSObject, MGLMapViewDelegate {
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MGLMapView, didChangeLocationManagerAuthorization manager: MGLLocationManager) {
|
||||
guard mapView.showsUserLocation else {
|
||||
return
|
||||
}
|
||||
|
||||
switch manager.authorizationStatus {
|
||||
case .restricted:
|
||||
fallthrough
|
||||
case .denied:
|
||||
errorSubject.send(.failedLocatingUser)
|
||||
errorSubject.send(.invalidLocationAuthorization)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
@@ -25,16 +25,20 @@ struct LocationSharingUserMarkerView: View {
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
@State private var frame: CGRect = .zero
|
||||
|
||||
// MARK: Public
|
||||
|
||||
let avatarData: AvatarInputProtocol
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .center) {
|
||||
ZStack {
|
||||
Image(uiImage: Asset.Images.locationUserMarker.image)
|
||||
AvatarImage(avatarData: avatarData, size: .large)
|
||||
.offset(.init(width: 0.0, height: -1.5))
|
||||
.offset(y: -1.5)
|
||||
}
|
||||
.background(ViewFrameReader(frame: $frame))
|
||||
.padding(.bottom, frame.height)
|
||||
.accentColor(theme.colors.accent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ struct LocationSharingView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
LocationSharingMapView(tileServerMapURL: context.viewState.tileServerMapURL,
|
||||
LocationSharingMapView(tileServerMapURL: context.viewState.mapStyleURL,
|
||||
avatarData: context.viewState.avatarData,
|
||||
location: context.viewState.location,
|
||||
errorSubject: context.viewState.errorSubject,
|
||||
@@ -54,6 +54,7 @@ struct LocationSharingView: View {
|
||||
context.send(viewAction: .share)
|
||||
} label: {
|
||||
Image(uiImage: Asset.Images.locationShareIcon.image)
|
||||
.accessibilityIdentifier("LocationSharingView.shareButton")
|
||||
}
|
||||
.disabled(!context.viewState.shareButtonEnabled)
|
||||
} else {
|
||||
@@ -69,6 +70,7 @@ struct LocationSharingView: View {
|
||||
.alert(item: $context.alertInfo) { info in
|
||||
if let secondaryButton = info.secondaryButton {
|
||||
return Alert(title: Text(info.title),
|
||||
message: subtitleTextForAlertInfo(info),
|
||||
primaryButton: .default(Text(info.primaryButton.title)) {
|
||||
info.primaryButton.action?()
|
||||
},
|
||||
@@ -77,6 +79,7 @@ struct LocationSharingView: View {
|
||||
})
|
||||
} else {
|
||||
return Alert(title: Text(info.title),
|
||||
message: subtitleTextForAlertInfo(info),
|
||||
dismissButton: .default(Text(info.primaryButton.title)) {
|
||||
info.primaryButton.action?()
|
||||
})
|
||||
@@ -85,6 +88,10 @@ struct LocationSharingView: View {
|
||||
}
|
||||
.accentColor(theme.colors.accent)
|
||||
.activityIndicator(show: context.viewState.showLoadingIndicator)
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.introspectNavigationController { navigationController in
|
||||
ThemeService.shared().theme.applyStyle(onNavigationBar: navigationController.navigationBar)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -93,6 +100,14 @@ struct LocationSharingView: View {
|
||||
ActivityIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
private func subtitleTextForAlertInfo(_ alertInfo: LocationSharingErrorAlertInfo) -> Text? {
|
||||
guard let subtitle = alertInfo.subtitle else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Text(subtitle)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
+15
-17
@@ -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
|
||||
|
||||
@@ -19,6 +19,8 @@ import SwiftUI
|
||||
@available(iOS 14.0.0, *)
|
||||
struct RoomNotificationSettings: View {
|
||||
|
||||
@Environment(\.theme) var theme: ThemeSwiftUI
|
||||
|
||||
@ObservedObject var viewModel: RoomNotificationSettingsSwiftUIViewModel
|
||||
|
||||
let presentedModally: Bool
|
||||
@@ -67,6 +69,7 @@ struct RoomNotificationSettings: View {
|
||||
.onAppear {
|
||||
viewModel.process(viewAction: .load)
|
||||
}
|
||||
.accentColor(theme.colors.accent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -32,15 +31,10 @@ final class PollEditFormCoordinator: Coordinator, Presentable {
|
||||
|
||||
private let parameters: PollEditFormCoordinatorParameters
|
||||
private let pollEditFormHostingController: UIViewController
|
||||
private var _pollEditFormViewModel: Any? = nil
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
fileprivate var pollEditFormViewModel: PollEditFormViewModel {
|
||||
return _pollEditFormViewModel as! PollEditFormViewModel
|
||||
}
|
||||
private var pollEditFormViewModel: PollEditFormViewModelProtocol
|
||||
|
||||
// MARK: Public
|
||||
|
||||
|
||||
var childCoordinators: [Coordinator] = []
|
||||
|
||||
var completion: (() -> Void)?
|
||||
@@ -51,10 +45,21 @@ 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 })))
|
||||
|
||||
_pollEditFormViewModel = viewModel
|
||||
} else {
|
||||
viewModel = PollEditFormViewModel(parameters: PollEditFormViewModelParameters(mode: .creation, pollDetails: .default))
|
||||
}
|
||||
|
||||
let view = PollEditForm(viewModel: viewModel.context)
|
||||
|
||||
pollEditFormViewModel = viewModel
|
||||
pollEditFormHostingController = VectorHostingController(rootView: view)
|
||||
}
|
||||
|
||||
@@ -70,37 +75,87 @@ 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)
|
||||
self.pollEditFormViewModel.startLoading()
|
||||
|
||||
self.parameters.room.sendPollStart(withContent: pollStartContent, localEcho: nil) { [weak self] result in
|
||||
self.parameters.room.sendPollStart(withContent: pollStartContent, threadId: nil, localEcho: nil) { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.pollEditFormViewModel.dispatch(action: .stopLoading(nil))
|
||||
self.pollEditFormViewModel.stopLoading()
|
||||
self.completion?()
|
||||
} failure: { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
|
||||
MXLog.error("Failed creating poll with error: \(String(describing: error))")
|
||||
self.pollEditFormViewModel.dispatch(action: .stopLoading(error))
|
||||
self.pollEditFormViewModel.stopLoading(errorAlertType: .failedCreatingPoll)
|
||||
}
|
||||
|
||||
case .update(let details):
|
||||
guard let pollStartEvent = self.parameters.pollStartEvent else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
self.pollEditFormViewModel.startLoading()
|
||||
|
||||
guard let oldPollContent = MXEventContentPollStart(fromJSON: pollStartEvent.content) else {
|
||||
self.pollEditFormViewModel.stopLoading(errorAlertType: .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.stopLoading()
|
||||
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.stopLoading(errorAlertType: .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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/PollEditForm PollEditForm
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
@@ -19,10 +17,25 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum PollEditFormStateAction {
|
||||
case viewAction(PollEditFormViewAction)
|
||||
case startLoading
|
||||
case stopLoading(Error?)
|
||||
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 PollEditFormViewAction {
|
||||
@@ -30,11 +43,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 +75,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 +95,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
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/PollEditForm PollEditForm
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
@@ -19,14 +17,20 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
struct PollEditFormViewModelParameters {
|
||||
let mode: PollEditFormMode
|
||||
let pollDetails: EditFormPollDetails
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
typealias PollEditFormViewModelType = StateStoreViewModel< PollEditFormViewState,
|
||||
PollEditFormStateAction,
|
||||
PollEditFormViewAction >
|
||||
typealias PollEditFormViewModelType = StateStoreViewModel <PollEditFormViewState,
|
||||
Never,
|
||||
PollEditFormViewAction>
|
||||
@available(iOS 14, *)
|
||||
class PollEditFormViewModel: PollEditFormViewModelType {
|
||||
class PollEditFormViewModel: PollEditFormViewModelType, PollEditFormViewModelProtocol {
|
||||
|
||||
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,37 +68,47 @@ 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
|
||||
})))
|
||||
default:
|
||||
dispatch(action: .viewAction(viewAction))
|
||||
completion?(.create(buildPollDetails()))
|
||||
case .update:
|
||||
completion?(.update(buildPollDetails()))
|
||||
case .addAnswerOption:
|
||||
state.bindings.answerOptions.append(PollEditFormAnswerOption(text: "", maxLength: Constants.maxAnswerOptionLength))
|
||||
case .deleteAnswerOption(let answerOption):
|
||||
state.bindings.answerOptions.removeAll { $0 == answerOption }
|
||||
}
|
||||
}
|
||||
|
||||
override class func reducer(state: inout PollEditFormViewState, action: PollEditFormStateAction) {
|
||||
switch action {
|
||||
case .viewAction(let viewAction):
|
||||
switch viewAction {
|
||||
case .deleteAnswerOption(let answerOption):
|
||||
state.bindings.answerOptions.removeAll { $0 == answerOption }
|
||||
case .addAnswerOption:
|
||||
state.bindings.answerOptions.append(PollEditFormAnswerOption(text: "", maxLength: Constants.maxAnswerOptionLength))
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .startLoading:
|
||||
state.showLoadingIndicator = true
|
||||
break
|
||||
case .stopLoading(let error):
|
||||
state.showLoadingIndicator = false
|
||||
|
||||
if error != nil {
|
||||
state.bindings.showsFailureAlert = true
|
||||
}
|
||||
|
||||
// MARK: - PollEditFormViewModelProtocol
|
||||
|
||||
func startLoading() {
|
||||
state.showLoadingIndicator = true
|
||||
}
|
||||
|
||||
func stopLoading(errorAlertType: PollEditFormErrorAlertInfo.AlertType?) {
|
||||
state.showLoadingIndicator = false
|
||||
|
||||
switch errorAlertType {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
+11
-5
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/UserSuggestion UserSuggestion
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
@@ -18,7 +16,15 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
@available(iOS 14, *)
|
||||
enum UserSuggestionViewModelResult {
|
||||
case selectedItemWithIdentifier(String)
|
||||
protocol PollEditFormViewModelProtocol {
|
||||
var completion: ((PollEditFormViewModelResult) -> Void)? { get set }
|
||||
|
||||
func startLoading()
|
||||
func stopLoading(errorAlertType: PollEditFormErrorAlertInfo.AlertType?)
|
||||
}
|
||||
|
||||
extension PollEditFormViewModelProtocol {
|
||||
func stopLoading() {
|
||||
stopLoading(errorAlertType: nil)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/PollEditForm PollEditForm
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -108,39 +121,8 @@ struct PollEditForm: View {
|
||||
}
|
||||
.accentColor(theme.colors.accent)
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
}
|
||||
}
|
||||
|
||||
@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")
|
||||
}
|
||||
.introspectNavigationController { navigationController in
|
||||
ThemeService.shared().theme.applyStyle(onNavigationBar: navigationController.navigationBar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
// 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.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
@available(iOS 14, *)
|
||||
typealias PollTimelineViewModelType = StateStoreViewModel<PollTimelineViewState,
|
||||
PollTimelineStateAction,
|
||||
PollTimelineViewAction>
|
||||
@available(iOS 14, *)
|
||||
class PollTimelineViewModel: PollTimelineViewModelType {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: PollTimelineViewModelCallback?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(timelinePoll: TimelinePoll) {
|
||||
super.init(initialViewState: PollTimelineViewState(poll: timelinePoll, bindings: PollTimelineViewStateBindings()))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: PollTimelineViewAction) {
|
||||
switch viewAction {
|
||||
case .selectAnswerOptionWithIdentifier(_):
|
||||
dispatch(action: .viewAction(viewAction, callback))
|
||||
}
|
||||
}
|
||||
|
||||
override class func reducer(state: inout PollTimelineViewState, action: PollTimelineStateAction) {
|
||||
switch action {
|
||||
case .viewAction(let viewAction, let callback):
|
||||
switch viewAction {
|
||||
|
||||
// Update local state. An update will be pushed from the coordinator once sent.
|
||||
case .selectAnswerOptionWithIdentifier(let identifier):
|
||||
guard !state.poll.closed else {
|
||||
return
|
||||
}
|
||||
|
||||
if (state.poll.maxAllowedSelections == 1) {
|
||||
updateSingleSelectPollLocalState(&state, selectedAnswerIdentifier: identifier, callback: callback)
|
||||
} else {
|
||||
updateMultiSelectPollLocalState(&state, selectedAnswerIdentifier: identifier, callback: callback)
|
||||
}
|
||||
}
|
||||
case .updateWithPoll(let poll):
|
||||
state.poll = poll
|
||||
case .showAnsweringFailure:
|
||||
state.bindings.showsAnsweringFailureAlert = true
|
||||
case .showClosingFailure:
|
||||
state.bindings.showsClosingFailureAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
static func updateSingleSelectPollLocalState(_ state: inout PollTimelineViewState, selectedAnswerIdentifier: String, callback: PollTimelineViewModelCallback?) {
|
||||
for answerOption in state.poll.answerOptions {
|
||||
if answerOption.selected {
|
||||
answerOption.selected = false
|
||||
|
||||
if(answerOption.count > 0) {
|
||||
answerOption.count = answerOption.count - 1
|
||||
state.poll.totalAnswerCount -= 1
|
||||
}
|
||||
}
|
||||
|
||||
if answerOption.id == selectedAnswerIdentifier {
|
||||
answerOption.selected = true
|
||||
answerOption.count += 1
|
||||
state.poll.totalAnswerCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
informCoordinatorOfSelectionUpdate(state: state, callback: callback)
|
||||
}
|
||||
|
||||
static func updateMultiSelectPollLocalState(_ state: inout PollTimelineViewState, selectedAnswerIdentifier: String, callback: PollTimelineViewModelCallback?) {
|
||||
let selectedAnswerOptions = state.poll.answerOptions.filter { $0.selected == true }
|
||||
|
||||
let isDeselecting = selectedAnswerOptions.filter { $0.id == selectedAnswerIdentifier }.count > 0
|
||||
|
||||
if !isDeselecting && selectedAnswerOptions.count >= state.poll.maxAllowedSelections {
|
||||
return
|
||||
}
|
||||
|
||||
for answerOption in state.poll.answerOptions where answerOption.id == selectedAnswerIdentifier {
|
||||
if answerOption.selected {
|
||||
answerOption.selected = false
|
||||
answerOption.count -= 1
|
||||
state.poll.totalAnswerCount -= 1
|
||||
} else {
|
||||
answerOption.selected = true
|
||||
answerOption.count += 1
|
||||
state.poll.totalAnswerCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
informCoordinatorOfSelectionUpdate(state: state, callback: callback)
|
||||
}
|
||||
|
||||
static func informCoordinatorOfSelectionUpdate(state: PollTimelineViewState, callback: PollTimelineViewModelCallback?) {
|
||||
let selectedIdentifiers = state.poll.answerOptions.compactMap { answerOption in
|
||||
answerOption.selected ? answerOption.id : nil
|
||||
}
|
||||
|
||||
callback?(.selectedAnswerOptionsWithIdentifiers(selectedIdentifiers))
|
||||
}
|
||||
}
|
||||
@@ -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: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-36
@@ -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: TimelinePollViewModelProtocol!
|
||||
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.completion = { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch result {
|
||||
@@ -72,12 +70,13 @@ final class PollTimelineCoordinator: Coordinator, Presentable, PollAggregatorDel
|
||||
|
||||
self.parameters.room.sendPollResponse(for: parameters.pollStartEvent,
|
||||
withAnswerIdentifiers: identifiers,
|
||||
threadId: nil,
|
||||
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.showAnsweringFailure()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -89,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, localEcho: nil, success: nil) { [weak self] error in
|
||||
self?.pollTimelineViewModel.dispatch(action: .showClosingFailure)
|
||||
parameters.room.sendPollEnd(for: parameters.pollStartEvent, threadId: nil, localEcho: nil, success: nil) { [weak self] error in
|
||||
self?.viewModel.showClosingFailure()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PollAggregatorDelegate
|
||||
|
||||
func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) {
|
||||
pollTimelineViewModel.dispatch(action: .updateWithPoll(buildTimelinePollFrom(aggregator.poll)))
|
||||
viewModel.updateWithPollDetails(buildTimelinePollFrom(aggregator.poll))
|
||||
}
|
||||
|
||||
func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) {
|
||||
@@ -124,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
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -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]
|
||||
}
|
||||
}
|
||||
+44
-7
@@ -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)
|
||||
|
||||
+18
-19
@@ -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
|
||||
}
|
||||
|
||||
@@ -86,7 +85,7 @@ class PollTimelineViewModelTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testClosedSelection() {
|
||||
context.viewState.poll.closed = true
|
||||
viewModel.state.poll.closed = true
|
||||
|
||||
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
|
||||
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
|
||||
@@ -97,7 +96,7 @@ class PollTimelineViewModelTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testSingleSelectionOnMax2Allowed() {
|
||||
context.viewState.poll.maxAllowedSelections = 2
|
||||
viewModel.state.poll.maxAllowedSelections = 2
|
||||
|
||||
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
|
||||
|
||||
@@ -107,7 +106,7 @@ class PollTimelineViewModelTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testSingleReselectionOnMax2Allowed() {
|
||||
context.viewState.poll.maxAllowedSelections = 2
|
||||
viewModel.state.poll.maxAllowedSelections = 2
|
||||
|
||||
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
|
||||
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
|
||||
@@ -118,7 +117,7 @@ class PollTimelineViewModelTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testMultipleSelectionOnMax2Allowed() {
|
||||
context.viewState.poll.maxAllowedSelections = 2
|
||||
viewModel.state.poll.maxAllowedSelections = 2
|
||||
|
||||
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
|
||||
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
|
||||
+45
-22
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/PollTimeline PollTimeline
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
@@ -19,20 +17,13 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
typealias PollTimelineViewModelCallback = ((PollTimelineViewModelResult) -> Void)
|
||||
typealias TimelinePollViewModelCallback = ((TimelinePollViewModelResult) -> Void)
|
||||
|
||||
enum PollTimelineStateAction {
|
||||
case viewAction(PollTimelineViewAction, PollTimelineViewModelCallback?)
|
||||
case updateWithPoll(TimelinePoll)
|
||||
case showAnsweringFailure
|
||||
case showClosingFailure
|
||||
}
|
||||
|
||||
enum PollTimelineViewAction {
|
||||
enum TimelinePollViewAction {
|
||||
case selectAnswerOptionWithIdentifier(String)
|
||||
}
|
||||
|
||||
enum PollTimelineViewModelResult {
|
||||
enum TimelinePollViewModelResult {
|
||||
case selectedAnswerOptionsWithIdentifiers([String])
|
||||
}
|
||||
|
||||
@@ -41,7 +32,7 @@ enum TimelinePollType {
|
||||
case undisclosed
|
||||
}
|
||||
|
||||
class TimelineAnswerOption: Identifiable {
|
||||
struct TimelinePollAnswerOption: Identifiable {
|
||||
var id: String
|
||||
var text: String
|
||||
var count: UInt
|
||||
@@ -57,35 +48,67 @@ class TimelineAnswerOption: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
class TimelinePoll {
|
||||
extension MutableCollection where Element == TimelinePollAnswerOption {
|
||||
mutating func updateEach(_ update: (inout Element) -> Void) {
|
||||
for index in indices {
|
||||
update(&self[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct 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
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//
|
||||
// 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 Combine
|
||||
|
||||
@available(iOS 14, *)
|
||||
typealias TimelinePollViewModelType = StateStoreViewModel<TimelinePollViewState,
|
||||
Never,
|
||||
TimelinePollViewAction>
|
||||
@available(iOS 14, *)
|
||||
class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelProtocol {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var completion: TimelinePollViewModelCallback?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(timelinePollDetails: TimelinePollDetails) {
|
||||
super.init(initialViewState: TimelinePollViewState(poll: timelinePollDetails, bindings: TimelinePollViewStateBindings()))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: TimelinePollViewAction) {
|
||||
switch viewAction {
|
||||
|
||||
// Update local state. An update will be pushed from the coordinator once sent.
|
||||
case .selectAnswerOptionWithIdentifier(let identifier):
|
||||
guard !state.poll.closed else {
|
||||
return
|
||||
}
|
||||
|
||||
if (state.poll.maxAllowedSelections == 1) {
|
||||
updateSingleSelectPollLocalState(selectedAnswerIdentifier: identifier, callback: completion)
|
||||
} else {
|
||||
updateMultiSelectPollLocalState(&state, selectedAnswerIdentifier: identifier, callback: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TimelinePollViewModelProtocol
|
||||
|
||||
func updateWithPollDetails(_ pollDetails: TimelinePollDetails) {
|
||||
state.poll = pollDetails
|
||||
}
|
||||
|
||||
func showAnsweringFailure() {
|
||||
state.bindings.alertInfo = TimelinePollErrorAlertInfo(id: .failedSubmittingAnswer,
|
||||
title: VectorL10n.pollTimelineVoteNotRegisteredTitle,
|
||||
subtitle: VectorL10n.pollTimelineVoteNotRegisteredSubtitle)
|
||||
}
|
||||
|
||||
func showClosingFailure() {
|
||||
state.bindings.alertInfo = TimelinePollErrorAlertInfo(id: .failedClosingPoll,
|
||||
title: VectorL10n.pollTimelineNotClosedTitle,
|
||||
subtitle: VectorL10n.pollTimelineNotClosedSubtitle)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
func updateSingleSelectPollLocalState(selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) {
|
||||
state.poll.answerOptions.updateEach { answerOption in
|
||||
if answerOption.selected {
|
||||
answerOption.selected = false
|
||||
|
||||
if(state.poll.answerOptions.count > 0) {
|
||||
answerOption.count = answerOption.count - 1
|
||||
state.poll.totalAnswerCount -= 1
|
||||
}
|
||||
}
|
||||
|
||||
if answerOption.id == selectedAnswerIdentifier {
|
||||
answerOption.selected = true
|
||||
answerOption.count += 1
|
||||
state.poll.totalAnswerCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
informCoordinatorOfSelectionUpdate(state: state, callback: callback)
|
||||
}
|
||||
|
||||
func updateMultiSelectPollLocalState(_ state: inout TimelinePollViewState, selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) {
|
||||
let selectedAnswerOptions = state.poll.answerOptions.filter { $0.selected == true }
|
||||
|
||||
let isDeselecting = selectedAnswerOptions.filter { $0.id == selectedAnswerIdentifier }.count > 0
|
||||
|
||||
if !isDeselecting && selectedAnswerOptions.count >= state.poll.maxAllowedSelections {
|
||||
return
|
||||
}
|
||||
|
||||
state.poll.answerOptions.updateEach { answerOption in
|
||||
if (answerOption.id != selectedAnswerIdentifier) {
|
||||
return
|
||||
}
|
||||
|
||||
if answerOption.selected {
|
||||
answerOption.selected = false
|
||||
answerOption.count -= 1
|
||||
state.poll.totalAnswerCount -= 1
|
||||
} else {
|
||||
answerOption.selected = true
|
||||
answerOption.count += 1
|
||||
state.poll.totalAnswerCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
informCoordinatorOfSelectionUpdate(state: state, callback: callback)
|
||||
}
|
||||
|
||||
func informCoordinatorOfSelectionUpdate(state: TimelinePollViewState, callback: TimelinePollViewModelCallback?) {
|
||||
let selectedIdentifiers = state.poll.answerOptions.compactMap { answerOption in
|
||||
answerOption.selected ? answerOption.id : nil
|
||||
}
|
||||
|
||||
callback?(.selectedAnswerOptionsWithIdentifiers(selectedIdentifiers))
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/UserSuggestion UserSuggestion
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
@@ -18,7 +16,12 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
@available(iOS 14, *)
|
||||
enum UserSuggestionViewAction {
|
||||
case selectedItem(UserSuggestionViewStateItem)
|
||||
protocol TimelinePollViewModelProtocol {
|
||||
@available(iOS 14, *)
|
||||
var context: TimelinePollViewModelType.Context { get }
|
||||
var completion: ((TimelinePollViewModelResult) -> Void)? { get set }
|
||||
|
||||
func updateWithPollDetails(_ pollDetails: TimelinePollDetails)
|
||||
func showAnsweringFailure()
|
||||
func showClosingFailure()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+19
-30
@@ -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()
|
||||
}
|
||||
+23
-20
@@ -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
|
||||
@@ -25,6 +23,11 @@ protocol UserSuggestionCoordinatorDelegate: AnyObject {
|
||||
func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?)
|
||||
}
|
||||
|
||||
struct UserSuggestionCoordinatorParameters {
|
||||
let mediaManager: MXMediaManager
|
||||
let room: MXRoom
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
final class UserSuggestionCoordinator: Coordinator, Presentable {
|
||||
|
||||
@@ -55,11 +58,12 @@ final class UserSuggestionCoordinator: Coordinator, Presentable {
|
||||
|
||||
roomMemberProvider = UserSuggestionCoordinatorRoomMemberProvider(room: parameters.room)
|
||||
userSuggestionService = UserSuggestionService(roomMemberProvider: roomMemberProvider)
|
||||
userSuggestionViewModel = UserSuggestionViewModel.makeUserSuggestionViewModel(userSuggestionService: userSuggestionService)
|
||||
|
||||
let view = UserSuggestionList(viewModel: userSuggestionViewModel.context)
|
||||
let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService)
|
||||
let view = UserSuggestionList(viewModel: viewModel.context)
|
||||
.addDependency(AvatarService.instantiate(mediaManager: parameters.mediaManager))
|
||||
|
||||
userSuggestionViewModel = viewModel
|
||||
userSuggestionHostingController = VectorHostingController(rootView: view)
|
||||
|
||||
userSuggestionViewModel.completion = { [weak self] result in
|
||||
@@ -92,7 +96,6 @@ final class UserSuggestionCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
private class UserSuggestionCoordinatorRoomMemberProvider: RoomMembersProviderProtocol {
|
||||
|
||||
private let room: MXRoom
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/UserSuggestion UserSuggestion
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
@@ -19,19 +17,16 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
struct RoomMembersProviderMember {
|
||||
var userId: String
|
||||
var displayName: String
|
||||
var avatarUrl: String
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
protocol RoomMembersProviderProtocol {
|
||||
func fetchMembers(_ members: @escaping ([RoomMembersProviderMember]) -> Void)
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
struct UserSuggestionServiceItem: UserSuggestionItemProtocol {
|
||||
let userId: String
|
||||
let displayName: String?
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/UserSuggestion UserSuggestion
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
@@ -19,7 +17,6 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
protocol UserSuggestionItemProtocol: Avatarable {
|
||||
var userId: String { get }
|
||||
var displayName: String? { get }
|
||||
@@ -38,7 +35,6 @@ protocol UserSuggestionServiceProtocol {
|
||||
|
||||
// MARK: Avatarable
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
extension UserSuggestionItemProtocol {
|
||||
var mxContentUri: String? {
|
||||
avatarUrl
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/UserSuggestion UserSuggestion
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/UserSuggestion UserSuggestion
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
|
||||
+8
-5
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/UserSuggestion UserSuggestion
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
@@ -17,16 +15,21 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
enum UserSuggestionViewAction {
|
||||
case selectedItem(UserSuggestionViewStateItem)
|
||||
}
|
||||
|
||||
enum UserSuggestionViewModelResult {
|
||||
case selectedItemWithIdentifier(String)
|
||||
}
|
||||
|
||||
struct UserSuggestionViewStateItem: Identifiable {
|
||||
let id: String
|
||||
let avatar: AvatarInputProtocol?
|
||||
let displayName: String?
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
struct UserSuggestionViewState: BindableState {
|
||||
var items: [UserSuggestionViewStateItem]
|
||||
}
|
||||
+2
-4
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/UserSuggestion UserSuggestion
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
@@ -26,12 +24,12 @@ enum MockUserSuggestionScreenState: MockScreenState, CaseIterable {
|
||||
static private var members: [RoomMembersProviderMember]!
|
||||
|
||||
var screenType: Any.Type {
|
||||
MockUserSuggestionScreenState.self
|
||||
UserSuggestionList.self
|
||||
}
|
||||
|
||||
var screenView: ([Any], AnyView) {
|
||||
let service = UserSuggestionService(roomMemberProvider: self)
|
||||
let listViewModel = UserSuggestionViewModel.makeUserSuggestionViewModel(userSuggestionService: service)
|
||||
let listViewModel = UserSuggestionViewModel(userSuggestionService: service)
|
||||
|
||||
let viewModel = UserSuggestionListWithInputViewModel(listViewModel: listViewModel) { textMessage in
|
||||
service.processTextMessage(textMessage)
|
||||
+14
-33
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/UserSuggestion UserSuggestion
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
@@ -19,11 +17,12 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
@available(iOS 14, *)
|
||||
@available(iOS 14.0, *)
|
||||
typealias UserSuggestionViewModelType = StateStoreViewModel <UserSuggestionViewState,
|
||||
UserSuggestionStateAction,
|
||||
Never,
|
||||
UserSuggestionViewAction>
|
||||
@available(iOS 14, *)
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewModelProtocol {
|
||||
|
||||
// MARK: - Properties
|
||||
@@ -37,30 +36,21 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo
|
||||
var completion: ((UserSuggestionViewModelResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
static func makeUserSuggestionViewModel(userSuggestionService: UserSuggestionServiceProtocol) -> UserSuggestionViewModelProtocol {
|
||||
return UserSuggestionViewModel(userSuggestionService: userSuggestionService)
|
||||
}
|
||||
|
||||
private init(userSuggestionService: UserSuggestionServiceProtocol) {
|
||||
init(userSuggestionService: UserSuggestionServiceProtocol) {
|
||||
self.userSuggestionService = userSuggestionService
|
||||
super.init(initialViewState: Self.defaultState(userSuggestionService: userSuggestionService))
|
||||
setupItemsObserving()
|
||||
}
|
||||
|
||||
private func setupItemsObserving() {
|
||||
let updatePublisher = userSuggestionService.items
|
||||
.map(UserSuggestionStateAction.updateWithItems)
|
||||
.eraseToAnyPublisher()
|
||||
dispatch(actionPublisher: updatePublisher)
|
||||
}
|
||||
|
||||
private static func defaultState(userSuggestionService: UserSuggestionServiceProtocol) -> UserSuggestionViewState {
|
||||
let viewStateItems = userSuggestionService.items.value.map { suggestionItem in
|
||||
|
||||
let items = userSuggestionService.items.value.map { suggestionItem in
|
||||
return UserSuggestionViewStateItem(id: suggestionItem.userId, avatar: suggestionItem, displayName: suggestionItem.displayName)
|
||||
}
|
||||
|
||||
return UserSuggestionViewState(items: viewStateItems)
|
||||
super.init(initialViewState: UserSuggestionViewState(items: items))
|
||||
|
||||
userSuggestionService.items.sink { items in
|
||||
self.state.items = items.map({ item in
|
||||
UserSuggestionViewStateItem(id: item.userId, avatar: item, displayName: item.displayName)
|
||||
})
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -71,13 +61,4 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo
|
||||
completion?(.selectedItemWithIdentifier(item.id))
|
||||
}
|
||||
}
|
||||
|
||||
override class func reducer(state: inout UserSuggestionViewState, action: UserSuggestionStateAction) {
|
||||
switch action {
|
||||
case .updateWithItems(let items):
|
||||
state.items = items.map({ item in
|
||||
UserSuggestionViewStateItem(id: item.userId, avatar: item, displayName: item.displayName)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-5
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/UserSuggestion UserSuggestion
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
@@ -18,7 +16,6 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct UserSuggestionCoordinatorParameters {
|
||||
let mediaManager: MXMediaManager
|
||||
let room: MXRoom
|
||||
protocol UserSuggestionViewModelProtocol {
|
||||
var completion: ((UserSuggestionViewModelResult) -> Void)? { get set }
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/UserSuggestion UserSuggestion
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// File created from SimpleUserProfileExample
|
||||
// $ createScreen.sh Room/UserSuggestion UserSuggestion
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
|
||||
@@ -18,7 +18,7 @@ import SwiftUI
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
struct UserSuggestionListWithInputViewModel {
|
||||
let listViewModel: UserSuggestionViewModelProtocol
|
||||
let listViewModel: UserSuggestionViewModel
|
||||
let callback: (String)->()
|
||||
}
|
||||
|
||||
|
||||
-29
@@ -1,29 +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
|
||||
|
||||
@available(iOS 14, *)
|
||||
protocol UserSuggestionViewModelProtocol {
|
||||
|
||||
static func makeUserSuggestionViewModel(userSuggestionService: UserSuggestionServiceProtocol) -> UserSuggestionViewModelProtocol
|
||||
|
||||
var context: UserSuggestionViewModelType.Context { get }
|
||||
|
||||
var completion: ((UserSuggestionViewModelResult) -> Void)? { get set }
|
||||
}
|
||||
Reference in New Issue
Block a user