Add AnalyticsPrompt to SwiftUI target and replace old UIAlertController.

This commit is contained in:
Doug
2021-12-06 12:52:33 +00:00
parent d4f5dbbd11
commit ee74bc16fb
24 changed files with 1554 additions and 71 deletions
@@ -0,0 +1,98 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh AnalyticsPrompt AnalyticsPrompt
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
// The state is never modified so this is unnecessary.
enum AnalyticsPromptStateAction { }
enum AnalyticsPromptViewAction {
/// Enable analytics.
case enable
/// Disable analytics.
case disable
/// Open the service terms link.
case openTermsURL
}
enum AnalyticsPromptViewModelResult {
/// Enable analytics.
case enable
/// Disable analytics.
case disable
}
struct AnalyticsPromptViewState: BindableState {
/// The type of prompt to display.
let promptType: AnalyticsPromptType
/// The app's bundle display name.
let appDisplayName: String
}
enum AnalyticsPromptType {
case newUser
case upgrade
}
extension AnalyticsPromptType {
var description: String {
switch self {
case .newUser:
return VectorL10n.analyticsPromptDescriptionNewUser
case .upgrade:
return VectorL10n.analyticsPromptDescriptionUpgrade
}
}
var termsStrings: (String, String, String) {
switch self {
case .newUser:
return (VectorL10n.analyticsPromptTermsStartNewUser,
VectorL10n.analyticsPromptTermsLinkNewUser,
VectorL10n.analyticsPromptTermsEndNewUser)
case .upgrade:
return (VectorL10n.analyticsPromptTermsStartUpgrade,
VectorL10n.analyticsPromptTermsLinkUpgrade,
VectorL10n.analyticsPromptTermsEndUpgrade)
}
}
var enableButtonTitle: String {
switch self {
case .newUser:
return VectorL10n.enable
case .upgrade:
return VectorL10n.analyticsPromptYes
}
}
var disableButtonTitle: String {
switch self {
case .newUser:
return VectorL10n.cancel
case .upgrade:
return VectorL10n.analyticsPromptStop
}
}
}
extension AnalyticsPromptType: CaseIterable { }
extension AnalyticsPromptType: Identifiable {
var id: Self { self }
}
@@ -0,0 +1,76 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh AnalyticsPrompt AnalyticsPrompt
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
import Combine
@available(iOS 14, *)
typealias AnalyticsPromptViewModelType = StateStoreViewModel<AnalyticsPromptViewState,
AnalyticsPromptStateAction,
AnalyticsPromptViewAction>
@available(iOS 14, *)
class AnalyticsPromptViewModel: AnalyticsPromptViewModelType {
// MARK: - Properties
// MARK: Private
// MARK: Public
var completion: ((AnalyticsPromptViewModelResult) -> Void)?
// MARK: - Setup
/// Initialize a view model with the specified prompt type and app display name.
init(promptType: AnalyticsPromptType, appDisplayName: String) {
super.init(initialViewState: AnalyticsPromptViewState(promptType: promptType, appDisplayName: appDisplayName))
}
// MARK: - Public
override func process(viewAction: AnalyticsPromptViewAction) {
switch viewAction {
case .enable:
enable()
case .disable:
disable()
case .openTermsURL:
openTermsURL()
}
}
override class func reducer(state: inout AnalyticsPromptViewState, action: AnalyticsPromptStateAction) {
// There is no mutable state to reduce :)
}
/// Enable analytics. The call to the Analytics class is made in the completion.
private func enable() {
completion?(.enable)
}
/// Disable analytics. The call to the Analytics class is made in the completion.
private func disable() {
completion?(.disable)
}
/// Open the service terms link.
private func openTermsURL() {
guard let url = URL(string: "https://element.io/cookie-policy") else { return }
UIApplication.shared.open(url)
}
}
@@ -0,0 +1,94 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh AnalyticsPrompt AnalyticsPrompt
/*
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import SwiftUI
struct AnalyticsPromptCoordinatorParameters {
/// The type of prompt to display.
let promptType: AnalyticsPromptType
/// The session to use if analytics are enabled.
let session: MXSession
/// The navigation router used to display the prompt.
let navigationRouter: NavigationRouterType
}
final class AnalyticsPromptCoordinator: Coordinator {
// MARK: - Properties
// MARK: Private
private let parameters: AnalyticsPromptCoordinatorParameters
private let analyticsPromptHostingController: UIViewController
private var _analyticsPromptViewModel: Any? = nil
@available(iOS 14.0, *)
fileprivate var analyticsPromptViewModel: AnalyticsPromptViewModel {
return _analyticsPromptViewModel as! AnalyticsPromptViewModel
}
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: (() -> Void)?
// MARK: - Setup
@available(iOS 14.0, *)
init(parameters: AnalyticsPromptCoordinatorParameters) {
self.parameters = parameters
let viewModel = AnalyticsPromptViewModel(promptType: parameters.promptType, appDisplayName: AppInfo.current.displayName)
let view = AnalyticsPrompt(viewModel: viewModel.context)
_analyticsPromptViewModel = viewModel
analyticsPromptHostingController = VectorHostingController(rootView: view)
}
// MARK: - Public
func start() {
guard #available(iOS 14.0, *) else {
MXLog.debug("[AnalyticsPromptCoordinator] start: Invalid iOS version, returning.")
return
}
MXLog.debug("[AnalyticsPromptCoordinator] did start.")
parameters.navigationRouter.present(toPresentable(), animated: true)
analyticsPromptViewModel.completion = { [weak self] result in
MXLog.debug("[AnalyticsPromptCoordinator] AnalyticsPromptViewModel did complete with result: \(result).")
guard let self = self else { return }
switch result {
case .enable:
Analytics.shared.optIn(with: self.parameters.session)
self.parameters.navigationRouter.dismissModule(animated: true, completion: nil)
case .disable:
Analytics.shared.optOut()
self.parameters.navigationRouter.dismissModule(animated: true, completion: nil)
}
}
}
func toPresentable() -> UIViewController {
return self.analyticsPromptHostingController
}
}
@@ -0,0 +1,54 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh AnalyticsPrompt AnalyticsPrompt
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
@available(iOS 14.0, *)
enum MockAnalyticsPromptScreenState: MockScreenState, CaseIterable {
/// The type of prompt to display.
case promptType(AnalyticsPromptType)
/// The associated screen
var screenType: Any.Type {
AnalyticsPrompt.self
}
/// A list of screen state definitions
static var allCases: [MockAnalyticsPromptScreenState] {
AnalyticsPromptType.allCases.map { MockAnalyticsPromptScreenState.promptType($0) }
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let promptType: AnalyticsPromptType
switch self {
case .promptType(let analyticsPromptType):
promptType = analyticsPromptType
}
let viewModel = AnalyticsPromptViewModel(promptType: promptType, appDisplayName: "Element")
return (
[promptType, viewModel],
AnyView(AnalyticsPrompt(viewModel: viewModel.context)
.addDependency(MockAvatarService.example))
)
}
}
@@ -0,0 +1,65 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh AnalyticsPrompt AnalyticsPrompt
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
import RiotSwiftUI
@available(iOS 14.0, *)
class AnalyticsPromptUITests: MockScreenTest {
override class var screenType: MockScreenState.Type {
return MockAnalyticsPromptScreenState.self
}
override class func createTest() -> MockScreenTest {
return AnalyticsPromptUITests(selector: #selector(verifyAnalyticsPromptScreen))
}
func verifyAnalyticsPromptScreen() throws {
guard let screenState = screenState as? MockAnalyticsPromptScreenState else { fatalError("no screen") }
switch screenState {
case .promptType(let promptType):
verifyAnalyticsPromptType(promptType)
}
}
/// Verify that the prompt is displayed correctly for new users compared to upgrading from Matomo
func verifyAnalyticsPromptType(_ promptType: AnalyticsPromptType) {
let enableButton = app.buttons["enableButton"]
let disableButton = app.buttons["disableButton"]
XCTAssert(enableButton.exists)
XCTAssert(disableButton.exists)
switch promptType {
case .newUser:
XCTAssertEqual(enableButton.label, VectorL10n.enable)
XCTAssertEqual(disableButton.label, VectorL10n.cancel)
case .upgrade:
XCTAssertEqual(enableButton.label, VectorL10n.analyticsPromptYes)
XCTAssertEqual(disableButton.label, VectorL10n.analyticsPromptStop)
}
}
func verifyAnalyticsPromptLongName(name: String) {
let displayNameText = app.staticTexts["displayNameText"]
XCTAssert(displayNameText.exists)
XCTAssertEqual(displayNameText.label, name)
}
}
@@ -0,0 +1,139 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh AnalyticsPrompt AnalyticsPrompt
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
@available(iOS 14.0, *)
/// A prompt that asks the user whether they would like to enable Analytics or not.
struct AnalyticsPrompt: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme
// MARK: Public
@ObservedObject var viewModel: AnalyticsPromptViewModel.Context
// MARK: Views
/// The text that explains what analytics will do.
private var descriptionText: some View {
VStack {
Text("\(viewModel.viewState.promptType.description)\n")
AnalyticsPromptTermsText(promptType: viewModel.viewState.promptType)
.onTapGesture {
viewModel.send(viewAction: .openTermsURL)
}
}
}
/// The list of re-assurances about analytics.
private var checkmarkList: some View {
VStack(alignment: .leading) {
Label {
Text(VectorL10n.analyticsPromptPoint1Start)
+ Text(VectorL10n.analyticsPromptPoint1BoldedDont).font(theme.fonts.bodySB)
+ Text(VectorL10n.analyticsPromptPoint1End)
} icon: {
Image(uiImage: Asset.Images.analyticsCheckmark.image)
}
Label {
Text(VectorL10n.analyticsPromptPoint2Start)
+ Text(VectorL10n.analyticsPromptPoint2BoldedDont).font(theme.fonts.bodySB)
+ Text(VectorL10n.analyticsPromptPoint2End)
} icon: {
Image(uiImage: Asset.Images.analyticsCheckmark.image)
}
Label {
Text(VectorL10n.analyticsPromptPoint3)
} icon: {
Image(uiImage: Asset.Images.analyticsCheckmark.image)
}
}
.font(theme.fonts.body)
}
/// The stack of enable/disable buttons.
private var buttons: some View {
VStack {
Button { viewModel.send(viewAction: .enable) } label: {
Text(viewModel.viewState.promptType.enableButtonTitle)
.font(theme.fonts.bodySB)
}
.buttonStyle(PrimaryActionButtonStyle())
.accessibilityIdentifier("enableButton")
Button { viewModel.send(viewAction: .disable) } label: {
Text(viewModel.viewState.promptType.disableButtonTitle)
.font(theme.fonts.bodySB)
.foregroundColor(theme.colors.accent)
}
.buttonStyle(PrimaryActionButtonStyle(customColor: .clear))
.accessibilityIdentifier("disableButton")
}
}
var body: some View {
VStack {
ScrollView(showsIndicators: false) {
VStack {
Image(uiImage: Asset.Images.analyticsLogo.image)
.padding(.bottom, 25)
Text(VectorL10n.analyticsPromptTitle(viewModel.viewState.appDisplayName))
.font(theme.fonts.title2B)
.foregroundColor(theme.colors.primaryContent)
.padding(.bottom, 2)
descriptionText
.foregroundColor(theme.colors.secondaryContent)
.multilineTextAlignment(.center)
Divider()
.background(theme.colors.quinaryContent)
.padding(.vertical, 28)
checkmarkList
.foregroundColor(theme.colors.secondaryContent)
}
.padding(.top, 50)
.padding(.horizontal, 16)
}
buttons
.padding(.horizontal, 16)
}
.background(theme.colors.background.ignoresSafeArea())
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct AnalyticsPrompt_Previews: PreviewProvider {
static let stateRenderer = MockAnalyticsPromptScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup()
}
}
@@ -0,0 +1,42 @@
//
// 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, *)
/// The last line of text in the description with highlighting on the link string.
struct AnalyticsPromptTermsText: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme
// MARK: Public
let promptType: AnalyticsPromptType
// MARK: Views
var body: some View {
let (start, link, end) = promptType.termsStrings
Text(start)
+ Text(link).foregroundColor(theme.colors.accent)
+ Text(end)
}
}