diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index ee73a44bb..3d80cdff3 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -14,6 +14,7 @@ "settings_appearance" = "Appearance"; "settings_timeline_style" = "Message layout"; "settings_session_verification" = "Complete verification"; +"settings_developer_options" = "Developer options"; "room_timeline_style_plain_long_description" = "Modern"; "room_timeline_style_bubbled_long_description" = "Bubbles"; diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index e0559f357..63f212c00 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -49,6 +49,15 @@ final class AppSettings: ObservableObject { // MARK: - Application + lazy var canShowDeveloperOptions: Bool = { + #if DEBUG + true + #else + let apps = ["io.element.elementx.nightly", "io.element.elementx.pr"] + return apps.contains(InfoPlistReader.main.baseBundleIdentifier) + #endif + }() + /// The last known version of the app that was launched on this device, which is /// used to detect when migrations should be run. When `nil` the app may have been /// deleted between runs so should clear data in the shared container and keychain. diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 52ee744e7..d39867a5f 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -140,6 +140,8 @@ extension ElementL10n { public static let sessionVerificationStart = ElementL10n.tr("Untranslated", "session_verification_start") /// Appearance public static let settingsAppearance = ElementL10n.tr("Untranslated", "settings_appearance") + /// Developer options + public static let settingsDeveloperOptions = ElementL10n.tr("Untranslated", "settings_developer_options") /// Complete verification public static let settingsSessionVerification = ElementL10n.tr("Untranslated", "settings_session_verification") /// Message layout diff --git a/ElementX/Sources/Other/EffectsScene/ConfettiScene.scn b/ElementX/Sources/Other/EffectsScene/ConfettiScene.scn new file mode 100644 index 000000000..2d914d31c Binary files /dev/null and b/ElementX/Sources/Other/EffectsScene/ConfettiScene.scn differ diff --git a/ElementX/Sources/Other/EffectsScene/EffectsScene.swift b/ElementX/Sources/Other/EffectsScene/EffectsScene.swift new file mode 100644 index 000000000..76003387b --- /dev/null +++ b/ElementX/Sources/Other/EffectsScene/EffectsScene.swift @@ -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 SceneKit +import SwiftUI + +class EffectsScene: SCNScene { + private enum Constants { + static let confettiSceneName = "ConfettiScene.scn" + static let particlesNodeName = "particles" + } + + static func confetti() -> EffectsScene? { + guard let scene = EffectsScene(named: Constants.confettiSceneName) else { return nil } + + let colors: [[Float]] = Color.element.contentAndAvatars.compactMap(\.floatComponents) + + if let particles = scene.rootNode.childNode(withName: Constants.particlesNodeName, recursively: false)?.particleSystems?.first { + // The particles need a non-zero color variation for the handler to affect the color + particles.particleColorVariation = SCNVector4(x: 0, y: 0, z: 0, w: 0.1) + + // Add a handler to customize the color of the particles. + particles.handle(.birth, forProperties: [.color]) { data, dataStride, _, count in + for index in 0.. SCNView { + SCNView(frame: .zero) + } + + func updateUIView(_ sceneView: SCNView, context: Context) { + sceneView.scene = makeScene() + sceneView.backgroundColor = .clear + } + + // MARK: - Private + + private func makeScene() -> EffectsScene? { + switch effect { + case .confetti: + return EffectsScene.confetti() + case .none: + return nil + } + } +} + +struct EffectsView_Previews: PreviewProvider { + static var previews: some View { + EffectsView(effect: .confetti) + } +} diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenCoordinator.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenCoordinator.swift new file mode 100644 index 000000000..99e8002a0 --- /dev/null +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenCoordinator.swift @@ -0,0 +1,29 @@ +// +// 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 SwiftUI + +final class DeveloperOptionsScreenCoordinator: CoordinatorProtocol { + private let viewModel: DeveloperOptionsScreenViewModelProtocol + + init() { + viewModel = DeveloperOptionsScreenViewModel() + } + + func toPresentable() -> AnyView { + AnyView(DeveloperOptionsScreenScreen(context: viewModel.context)) + } +} diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift new file mode 100644 index 000000000..42fafe250 --- /dev/null +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -0,0 +1,27 @@ +// +// 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 + +enum DeveloperOptionsScreenViewModelAction { } + +struct DeveloperOptionsScreenViewState: BindableState { + var bindings: DeveloperOptionsScreenViewStateBindings +} + +struct DeveloperOptionsScreenViewStateBindings { } + +enum DeveloperOptionsScreenViewAction { } diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift new file mode 100644 index 000000000..72c8dbf71 --- /dev/null +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift @@ -0,0 +1,27 @@ +// +// 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 SwiftUI + +typealias DeveloperOptionsScreenViewModelType = StateStoreViewModel + +class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, DeveloperOptionsScreenViewModelProtocol { + var callback: ((DeveloperOptionsScreenViewModelAction) -> Void)? + + init() { + super.init(initialViewState: DeveloperOptionsScreenViewState(bindings: DeveloperOptionsScreenViewStateBindings())) + } +} diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModelProtocol.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModelProtocol.swift new file mode 100644 index 000000000..257b5754d --- /dev/null +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModelProtocol.swift @@ -0,0 +1,23 @@ +// +// 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 + +@MainActor +protocol DeveloperOptionsScreenViewModelProtocol { + var callback: ((DeveloperOptionsScreenViewModelAction) -> Void)? { get set } + var context: DeveloperOptionsScreenViewModelType.Context { get } +} diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreenScreen.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreenScreen.swift new file mode 100644 index 000000000..3ad177261 --- /dev/null +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreenScreen.swift @@ -0,0 +1,64 @@ +// +// 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 SwiftUI + +struct DeveloperOptionsScreenScreen: View { + @ObservedObject var context: DeveloperOptionsScreenViewModel.Context + @State private var showConfetti = false + + var body: some View { + Form { + Section { + Button { + showConfetti = true + } label: { + Text("🥳") + .frame(maxWidth: .infinity) + } + } + .listRowBackground(Color.element.formRowBackground) + } + .overlay(effectsView) + .scrollContentBackground(.hidden) + .background(Color.element.formBackground.ignoresSafeArea()) + .navigationTitle(ElementL10n.settingsDeveloperOptions) + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private var effectsView: some View { + if showConfetti { + EffectsView(effect: .confetti) + .task { await removeConfettiAfterDelay() } + } + } + + private func removeConfettiAfterDelay() async { + try? await Task.sleep(for: .seconds(4)) + showConfetti = false + } +} + +// MARK: - Previews + +struct DeveloperOptionsScreen_Previews: PreviewProvider { + static var previews: some View { + let viewModel = DeveloperOptionsScreenViewModel() + DeveloperOptionsScreenScreen(context: viewModel.context) + .tint(.element.accent) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 3f4e571b7..4fedfe12c 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -88,7 +88,7 @@ struct RoomScreenViewStateBindings { /// Information describing the currently displayed alert. var alertInfo: AlertInfo? - var debugInfo: DebugScreen.DebugInfo? + var debugInfo: TimelineItemDebugView.DebugInfo? } enum RoomScreenErrorType: Hashable { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index b5101951b..74574ee8d 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -229,7 +229,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol actions.append(.redact) } - var debugActions: [TimelineItemContextMenuAction] = [.viewSource] + var debugActions: [TimelineItemContextMenuAction] = ServiceLocator.shared.settings.canShowDeveloperOptions ? [.viewSource] : [] if let item = timelineItem as? EncryptedRoomTimelineItem, case let .megolmV1AesSha2(sessionID) = item.encryptionType { diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 4094cbf07..38a6d1a0a 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -30,7 +30,7 @@ struct RoomScreen: View { .toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background. .overlay { loadingIndicator } .alert(item: $context.alertInfo) { $0.alert } - .sheet(item: $context.debugInfo) { DebugScreen(info: $0) } + .sheet(item: $context.debugInfo) { TimelineItemDebugView(info: $0) } .task(id: context.viewState.roomId) { // Give a couple of seconds for items to load and to see them. try? await Task.sleep(for: .seconds(2)) diff --git a/ElementX/Sources/Screens/RoomScreen/View/DebugScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemDebugView.swift similarity index 97% rename from ElementX/Sources/Screens/RoomScreen/View/DebugScreen.swift rename to ElementX/Sources/Screens/RoomScreen/View/TimelineItemDebugView.swift index 2117b8a70..412b6f2ad 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/DebugScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemDebugView.swift @@ -16,7 +16,7 @@ import SwiftUI -struct DebugScreen: View { +struct TimelineItemDebugView: View { struct DebugInfo: Identifiable { let id = UUID() let title: String diff --git a/ElementX/Sources/Screens/Settings/SettingsScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/SettingsScreenCoordinator.swift index 813aabc2c..a280a2508 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreenCoordinator.swift @@ -52,6 +52,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { self.presentBugReportScreen() case .sessionVerification: self.verifySession() + case .developerOptions: + self.presentDeveloperOptions() case .logout: self.callback?(.logout) } @@ -110,6 +112,11 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { self?.parameters.navigationStackCoordinator?.setSheetCoordinator(nil) } } + + private func presentDeveloperOptions() { + let coordinator = DeveloperOptionsScreenCoordinator() + parameters.navigationStackCoordinator?.push(coordinator) + } private func showSuccess(label: String) { parameters.userIndicatorController?.submitIndicator(UserIndicator(title: label)) diff --git a/ElementX/Sources/Screens/Settings/SettingsScreenModels.swift b/ElementX/Sources/Screens/Settings/SettingsScreenModels.swift index 37cceab2d..43ad6ab4e 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreenModels.swift @@ -22,6 +22,7 @@ enum SettingsScreenViewModelAction { case toggleAnalytics case reportBug case sessionVerification + case developerOptions case logout } @@ -32,6 +33,7 @@ struct SettingsScreenViewState: BindableState { var userAvatarURL: URL? var userDisplayName: String? var showSessionVerificationSection: Bool + var showDeveloperOptions: Bool } struct SettingsScreenViewStateBindings { @@ -45,4 +47,5 @@ enum SettingsScreenViewAction { case sessionVerification case logout case changedTimelineStyle + case developerOptions } diff --git a/ElementX/Sources/Screens/Settings/SettingsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/SettingsScreenViewModel.swift index 689243e70..82d22bb42 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreenViewModel.swift @@ -23,14 +23,15 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo private let userSession: UserSessionProtocol var callback: ((SettingsScreenViewModelAction) -> Void)? - + init(withUserSession userSession: UserSessionProtocol) { self.userSession = userSession let bindings = SettingsScreenViewStateBindings(timelineStyle: ServiceLocator.shared.settings.timelineStyle) super.init(initialViewState: .init(bindings: bindings, deviceID: userSession.deviceId, userID: userSession.userID, - showSessionVerificationSection: !(userSession.sessionVerificationController?.isVerified ?? false)), + showSessionVerificationSection: !(userSession.sessionVerificationController?.isVerified ?? false), + showDeveloperOptions: ServiceLocator.shared.settings.canShowDeveloperOptions), imageProvider: userSession.mediaProvider) ServiceLocator.shared.settings.$timelineStyle @@ -78,6 +79,8 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo callback?(.sessionVerification) case .changedTimelineStyle: ServiceLocator.shared.settings.timelineStyle = state.bindings.timelineStyle + case .developerOptions: + callback?(.developerOptions) } } } diff --git a/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift b/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift index 96b8c8756..dc61df767 100644 --- a/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift @@ -18,7 +18,6 @@ import SwiftUI struct SettingsScreen: View { @State private var showingLogoutConfirmation = false - @Environment(\.colorScheme) private var colorScheme @ScaledMetric private var avatarSize = AvatarSize.user(on: .settings).value @ScaledMetric private var menuIconSize = 30.0 @@ -40,6 +39,11 @@ struct SettingsScreen: View { simplifiedSection .listRowBackground(Color.element.formRowBackground) + if context.viewState.showDeveloperOptions { + developerOptionsSection + .listRowBackground(Color.element.formRowBackground) + } + signOutSection .listRowBackground(Color.element.formRowBackground) } @@ -89,6 +93,16 @@ struct SettingsScreen: View { } } + private var developerOptionsSection: some View { + Section { + SettingsDefaultRow(title: ElementL10n.settingsDeveloperOptions, + image: Image(systemName: "hammer.circle")) { + context.send(viewAction: .developerOptions) + } + .accessibilityIdentifier("sessionVerificationButton") + } + } + private var simplifiedSection: some View { Section { SettingsPickerRow(title: ElementL10n.settingsTimelineStyle, diff --git a/UITests/Sources/DeveloperOptionsScreenScreenUITests.swift b/UITests/Sources/DeveloperOptionsScreenScreenUITests.swift new file mode 100644 index 000000000..a823f3c56 --- /dev/null +++ b/UITests/Sources/DeveloperOptionsScreenScreenUITests.swift @@ -0,0 +1,20 @@ +// +// 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 ElementX +import XCTest + +class DeveloperOptionsScreenScreenUITests: XCTestCase { } diff --git a/UnitTests/Sources/DeveloperOptionsScreenViewModelTests.swift b/UnitTests/Sources/DeveloperOptionsScreenViewModelTests.swift new file mode 100644 index 000000000..802b3967e --- /dev/null +++ b/UnitTests/Sources/DeveloperOptionsScreenViewModelTests.swift @@ -0,0 +1,22 @@ +// +// 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 XCTest + +@testable import ElementX + +@MainActor +class DeveloperOptionsScreenViewModelTests: XCTestCase { }