Split out models, add some tests and fix some formatting.

This commit is contained in:
David Langley
2022-10-09 22:54:39 +01:00
parent dac94cbebf
commit 2e46c2c687
10 changed files with 266 additions and 143 deletions
@@ -0,0 +1,43 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI
enum MockComposerCreateActionListScreenState: MockScreenState, CaseIterable {
case partialList
case fullList
var screenType: Any.Type {
ComposerCreateActionList.self
}
var screenView: ([Any], AnyView) {
let actions: [ComposerCreateAction]
switch self {
case .partialList:
actions = [.photoLibrary, .polls]
case .fullList:
actions = ComposerCreateAction.allCases
}
let viewModel = ComposerCreateActionListViewModel(initialViewState: ComposerCreateActionListViewState(actions: actions))
return (
[viewModel],
AnyView(ComposerCreateActionList(viewModel: viewModel.context))
)
}
}
@@ -0,0 +1,74 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
@objc enum ComposerCreateAction: Int {
case photoLibrary
case stickers
case attachments
case polls
case location
case camera
}
extension ComposerCreateAction: Equatable, CaseIterable, Identifiable {
var id: Self { self }
}
extension ComposerCreateAction {
var title: String {
switch self {
case .photoLibrary:
return VectorL10n.wysiwygComposerStartActionMediaPicker
case .stickers:
return VectorL10n.wysiwygComposerStartActionStickers
case .attachments:
return VectorL10n.wysiwygComposerStartActionAttachments
case .polls:
return VectorL10n.wysiwygComposerStartActionPolls
case .location:
return VectorL10n.wysiwygComposerStartActionLocation
case .camera:
return VectorL10n.wysiwygComposerStartActionCamera
}
}
var icon: String {
switch self {
case .photoLibrary:
return Asset.Images.actionMediaLibrary.name
case .stickers:
return Asset.Images.actionSticker.name
case .attachments:
return Asset.Images.actionFile.name
case .polls:
return Asset.Images.actionPoll.name
case .location:
return Asset.Images.actionLocation.name
case .camera:
return Asset.Images.actionCamera.name
}
}
}
struct ComposerCreateActionListViewState: BindableState {
let actions: [ComposerCreateAction]
}
enum ComposerCreateActionListViewModelResult: Equatable {
case done(ComposerCreateAction)
}
@@ -0,0 +1,34 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import RiotSwiftUI
import XCTest
class ComposerCreateActionListUITests: MockScreenTestCase {
func testFullList() throws {
app.goToScreenWithIdentifier(MockComposerCreateActionListScreenState.fullList.title)
XCTAssert(app.staticTexts["Photo Library"].exists)
XCTAssert(app.staticTexts["Location"].exists)
}
func testPartialList() throws {
app.goToScreenWithIdentifier(MockComposerCreateActionListScreenState.partialList.title)
XCTAssert(app.staticTexts["Photo Library"].exists)
XCTAssertFalse(app.staticTexts["Location"].exists)
}
}
@@ -0,0 +1,41 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
@testable import RiotSwiftUI
import SwiftUI
import XCTest
class ComposerCreateActionListTests: XCTestCase {
var viewModel: ComposerCreateActionListViewModel!
var context: ComposerCreateActionListViewModel.Context!
override func setUpWithError() throws {
viewModel = ComposerCreateActionListViewModel(initialViewState: ComposerCreateActionListViewState(actions: ComposerCreateAction.allCases))
context = viewModel.context
}
func testSelection() throws {
let actionToSelect: ComposerCreateAction = .attachments
var result: ComposerCreateActionListViewModelResult?
viewModel.callback = { callbackResult in
result = callbackResult
}
viewModel.context.send(viewAction: .selectAction(actionToSelect))
XCTAssertEqual(result, .done(actionToSelect))
}
}
@@ -17,15 +17,12 @@
import SwiftUI
struct ComposerCreateActionList: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
@ObservedObject var viewModel: ComposerCreateActionListViewModel.Context
var body: some View {
VStack{
VStack {
VStack(alignment: .leading) {
ForEach(viewModel.viewState.actions) { action in
HStack(spacing: 16) {
@@ -51,9 +48,11 @@ struct ComposerCreateActionList: View {
}
}
// MARK: - Previews
struct ComposerCreateActionList_Previews: PreviewProvider {
static let viewModel = ComposerCreateActionListViewModel(initialViewState: ComposerCreateActionListViewState(actions: ComposerCreateAction.allCases))
static let stateRenderer = MockComposerCreateActionListScreenState.stateRenderer
static var previews: some View {
ComposerCreateActionList(viewModel: viewModel.context)
stateRenderer.screenGroup()
}
}
@@ -15,10 +15,9 @@
//
import Foundation
import WysiwygComposer
import SwiftUI
import WysiwygComposer
@available(iOS 15.0, *)
enum MockComposerScreenState: MockScreenState, CaseIterable {
case composer
@@ -26,18 +25,12 @@ enum MockComposerScreenState: MockScreenState, CaseIterable {
Composer.self
}
// var screenContainer: some View {
// VStack{
// Spacer()
// Composer(viewModel: viewModel)
// }
// }
var screenView: ([Any], AnyView) {
var screenView: ([Any], AnyView) {
let viewModel = WysiwygComposerViewModel(minHeight: 20, maxHeight: 360)
return (
[viewModel],
AnyView(VStack{
AnyView(VStack {
Spacer()
Composer(viewModel: viewModel, sendMessageAction: { _ in }, showSendMediaActions: { })
}.frame(
@@ -31,15 +31,6 @@ enum FormatType {
case underline
}
@objc enum ComposerCreateAction: Int {
case photoLibrary
case stickers
case attachments
case polls
case location
case camera
}
extension FormatType: CaseIterable, Identifiable {
var id: Self { self }
}
@@ -104,52 +95,3 @@ extension FormatType {
}
}
}
extension ComposerCreateAction: CaseIterable, Identifiable {
var id: Self { self }
}
extension ComposerCreateAction {
var title: String {
switch self {
case .photoLibrary:
return VectorL10n.wysiwygComposerStartActionMediaPicker
case .stickers:
return VectorL10n.wysiwygComposerStartActionStickers
case .attachments:
return VectorL10n.wysiwygComposerStartActionAttachments
case .polls:
return VectorL10n.wysiwygComposerStartActionPolls
case .location:
return VectorL10n.wysiwygComposerStartActionLocation
case .camera:
return VectorL10n.wysiwygComposerStartActionCamera
}
}
var icon: String {
switch self {
case .photoLibrary:
return Asset.Images.actionMediaLibrary.name
case .stickers:
return Asset.Images.actionSticker.name
case .attachments:
return Asset.Images.actionFile.name
case .polls:
return Asset.Images.actionPoll.name
case .location:
return Asset.Images.actionLocation.name
case .camera:
return Asset.Images.actionCamera.name
}
}
}
struct ComposerCreateActionListViewState: BindableState {
let actions: [ComposerCreateAction]
}
enum ComposerCreateActionListViewModelResult {
case done(ComposerCreateAction)
}
@@ -14,32 +14,29 @@
// limitations under the License.
//
import DSBottomSheet
import SwiftUI
import WysiwygComposer
import DSBottomSheet
@available(iOS 15.0, *)
struct Composer: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
@ObservedObject var viewModel: WysiwygComposerViewModel
let sendMessageAction: (WysiwygComposerContent) -> Void
let showSendMediaActions: () -> Void
var textColor = Color(.label)
@State private var showSendButton = false
private let borderHeight: CGFloat = 44
private let minTextViewHeight: CGFloat = 20
private var verticalPadding: CGFloat {
(borderHeight - minTextViewHeight) / 2
}
private var formatItems: [FormatItem] {
FormatType.allCases.map { type in
return FormatItem(
FormatItem(
type: type,
active: viewModel.reversedActions.contains(type.composerAction),
disabled: viewModel.disabledActions.contains(type.composerAction)
@@ -48,8 +45,9 @@ struct Composer: View {
}
var body: some View {
VStack {
let rect = RoundedRectangle(cornerRadius: borderHeight / 2)
VStack {
let rect = RoundedRectangle(cornerRadius: borderHeight / 2)
ZStack(alignment: .topTrailing) {
WysiwygComposerView(
content: viewModel.content,
replaceText: viewModel.replaceText,
@@ -62,71 +60,68 @@ struct Composer: View {
.onAppear {
viewModel.setup()
}
.overlay(alignment: .topTrailing) {
Button {
withAnimation(.easeInOut(duration: 0.25)) {
viewModel.maximised.toggle()
}
} label: {
Image(viewModel.maximised ? Asset.Images.minimiseComposer.name : Asset.Images.maximiseComposer.name)
.foregroundColor(theme.colors.tertiaryContent)
Button {
withAnimation(.easeInOut(duration: 0.25)) {
viewModel.maximised.toggle()
}
.padding(.top, 4)
.padding(.trailing, 12)
} label: {
Image(viewModel.maximised ? Asset.Images.minimiseComposer.name : Asset.Images.maximiseComposer.name)
.foregroundColor(theme.colors.tertiaryContent)
}
.padding(.vertical, verticalPadding)
.clipShape(rect)
.overlay(rect.stroke(theme.colors.quinaryContent, lineWidth: 2))
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 4)
HStack{
Button {
showSendMediaActions()
} label: {
Image(Asset.Images.startComposeModule.name)
.foregroundColor(theme.colors.tertiaryContent)
.padding(11)
.background(Circle().fill(theme.colors.system))
}
FormattingToolbar(formatItems: formatItems) { type in
viewModel.apply(type.action)
}
Spacer()
ZStack{
Button {
} label: {
Image(Asset.Images.voiceMessageRecordButtonDefault.name)
.foregroundColor(theme.colors.tertiaryContent)
}
// TODO Add support for voice messages
// .isHidden(showSendButton)
.isHidden(true)
Button {
sendMessageAction(viewModel.content)
viewModel.clearContent()
} label: {
Image(Asset.Images.sendIcon.name)
.foregroundColor(theme.colors.tertiaryContent)
}
.isHidden(!showSendButton)
}.onChange(of: viewModel.isContentEmpty) { (empty) in
withAnimation(.easeInOut(duration: 0.25)) {
showSendButton = !empty
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 4)
.animation(.none)
.padding(.top, 4)
.padding(.trailing, 12)
}
.padding(.vertical, verticalPadding)
.clipShape(rect)
.overlay(rect.stroke(theme.colors.quinaryContent, lineWidth: 2))
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 4)
HStack {
Button {
showSendMediaActions()
} label: {
Image(Asset.Images.startComposeModule.name)
.foregroundColor(theme.colors.tertiaryContent)
.padding(11)
.background(Circle().fill(theme.colors.system))
}
FormattingToolbar(formatItems: formatItems) { type in
viewModel.apply(type.action)
}
Spacer()
ZStack {
// TODO: Add support for voice messages
// Button {
//
// } label: {
// Image(Asset.Images.voiceMessageRecordButtonDefault.name)
// .foregroundColor(theme.colors.tertiaryContent)
// }
// .isHidden(showSendButton)
// .isHidden(true)
Button {
sendMessageAction(viewModel.content)
viewModel.clearContent()
} label: {
Image(Asset.Images.sendIcon.name)
.foregroundColor(theme.colors.tertiaryContent)
}
.isHidden(!showSendButton)
}
.onChange(of: viewModel.isContentEmpty) { empty in
withAnimation(.easeInOut(duration: 0.25)) {
showSendButton = !empty
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 4)
.animation(.none)
}
}
}
@available(iOS 15.0, *)
struct Composer_Previews: PreviewProvider {
static let stateRenderer = MockComposerScreenState.stateRenderer
static var previews: some View {