Design review for first public TestFlight (#492)

* 430 - Design review for first public TestFlight
This commit is contained in:
Gil Eluard
2023-01-26 16:32:39 +01:00
committed by GitHub
parent 413b433806
commit eeb69d1971
57 changed files with 5887 additions and 5169 deletions
+4 -4
View File
@@ -25,10 +25,10 @@ internal enum Asset {
internal enum Images {
internal static let analyticsCheckmark = ImageAsset(name: "Images/AnalyticsCheckmark")
internal static let analyticsLogo = ImageAsset(name: "Images/AnalyticsLogo")
internal static let onboardingScreenPage1 = ImageAsset(name: "Images/Onboarding Screen Page 1")
internal static let onboardingScreenPage2 = ImageAsset(name: "Images/Onboarding Screen Page 2")
internal static let onboardingScreenPage3 = ImageAsset(name: "Images/Onboarding Screen Page 3")
internal static let onboardingScreenPage4 = ImageAsset(name: "Images/Onboarding Screen Page 4")
internal static let onboardingAppLogo = ImageAsset(name: "Images/Onboarding App Logo")
internal static let onboardingBackgroundPart1 = ImageAsset(name: "Images/Onboarding Background Part 1")
internal static let onboardingBackgroundPart2 = ImageAsset(name: "Images/Onboarding Background Part 2")
internal static let onboardingBackgroundPart3 = ImageAsset(name: "Images/Onboarding Background Part 3")
internal static let serverSelectionIcon = ImageAsset(name: "Images/Server Selection Icon")
internal static let encryptionNormal = ImageAsset(name: "Images/encryption_normal")
internal static let encryptionTrusted = ImageAsset(name: "Images/encryption_trusted")
@@ -16,10 +16,26 @@ extension ElementL10n {
public static let actionConfirm = ElementL10n.tr("Untranslated", "action_confirm")
/// Match
public static let actionMatch = ElementL10n.tr("Untranslated", "action_match")
/// Please describe the bug. What did you do? What did you expect to happen? What actually happened. Please go into as much detail as you can.
public static let bugReportScreenDescription = ElementL10n.tr("Untranslated", "bug_report_screen_description")
/// Send logs to help
public static let bugReportScreenIncludeLogs = ElementL10n.tr("Untranslated", "bug_report_screen_include_logs")
/// To check things work as intended, logs will be sent with your message. These will be private. To just send your message, turn off this setting.
public static let bugReportScreenLogsDescription = ElementL10n.tr("Untranslated", "bug_report_screen_logs_description")
/// Report a bug
public static let bugReportScreenTitle = ElementL10n.tr("Untranslated", "bug_report_screen_title")
/// %@ iOS
public static func defaultSessionDisplayName(_ p1: Any) -> String {
return ElementL10n.tr("Untranslated", "default_session_display_name", String(describing: p1))
}
/// Welcome to the %@ Beta. Supercharged, for speed and simplicity.
public static func ftueAuthCarouselWelcomeBody(_ p1: Any) -> String {
return ElementL10n.tr("Untranslated", "ftue_auth_carousel_welcome_body", String(describing: p1))
}
/// Be in your Element
public static let ftueAuthCarouselWelcomeTitle = ElementL10n.tr("Untranslated", "ftue_auth_carousel_welcome_title")
/// Enter your details
public static let ftueAuthSignInEnterDetails = ElementL10n.tr("Untranslated", "ftue_auth_sign_in_enter_details")
/// Mobile
public static let loginMobileDevice = ElementL10n.tr("Untranslated", "login_mobile_device")
/// Tablet
@@ -70,6 +86,8 @@ extension ElementL10n {
public static let notification = ElementL10n.tr("Untranslated", "Notification")
/// About
public static let roomDetailsAboutSectionTitle = ElementL10n.tr("Untranslated", "room_details_about_section_title")
/// Copy Link
public static let roomDetailsCopyLink = ElementL10n.tr("Untranslated", "room_details_copy_link")
/// Info
public static let roomDetailsTitle = ElementL10n.tr("Untranslated", "room_details_title")
/// Failed loading messages
@@ -96,7 +114,7 @@ extension ElementL10n {
public static let screenshotDetectedMessage = ElementL10n.tr("Untranslated", "screenshot_detected_message")
/// You took a screenshot
public static let screenshotDetectedTitle = ElementL10n.tr("Untranslated", "screenshot_detected_title")
/// You can only connect to a server that has already been set up
/// You can only connect to an existing server
public static let serverSelectionServerFooter = ElementL10n.tr("Untranslated", "server_selection_server_footer")
/// Looks like you’re using a new device. Verify its you.
public static let sessionVerificationBannerMessage = ElementL10n.tr("Untranslated", "session_verification_banner_message")
+2 -2
View File
@@ -56,7 +56,7 @@ enum UserAvatarSizeOnScreen {
case .settings:
return 60
case .roomDetails:
return 32
return 44
}
}
}
@@ -73,7 +73,7 @@ enum RoomAvatarSizeOnScreen {
case .home:
return 44
case .details:
return 100
return 70
}
}
}
@@ -0,0 +1,33 @@
//
// 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
/// Style for section header
struct FormSectionHeaderStyle: ViewModifier {
func body(content: Content) -> some View {
content
.foregroundColor(.element.secondaryContent)
.font(.element.footnoteBold)
}
}
extension View {
/// Applies the `FormSectionHeaderStyle` modifier to the view
func formSectionHeader() -> some View {
modifier(FormSectionHeaderStyle())
}
}
@@ -0,0 +1,47 @@
//
// Copyright 2023 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
/// Small squared action button for settings screens
struct SettingsActionButton: View {
// MARK: Public
let title: String
let image: Image
let action: () -> Void
// MARK: Private
@ScaledMetric private var menuIconSize = 54.0
// MARK: Views
var body: some View {
Button(action: action) {
VStack {
image
.renderingMode(.template)
.foregroundColor(.element.primaryContent)
.frame(width: menuIconSize, height: menuIconSize)
.background(RoundedRectangle(cornerRadius: 16).fill(Color.element.background))
Text(title)
.foregroundColor(.element.secondaryContent)
.font(.element.subheadline)
}
}
}
}
@@ -0,0 +1,58 @@
//
// Copyright 2023 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
/// Default row that can be reused for settings screens
struct SettingsDefaultRow: View {
// MARK: Public
let title: String
let image: Image
let action: () -> Void
// MARK: Private
@ScaledMetric private var menuIconSize = 30.0
private let listRowInsets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)
// MARK: Views
var body: some View {
Button(action: action) {
HStack(spacing: 16) {
image
.foregroundColor(.element.systemGray)
.padding(4)
.background(Color.element.systemGray6)
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: menuIconSize, height: menuIconSize)
Text(title)
.font(.element.body)
.foregroundColor(.element.primaryContent)
Spacer()
Image(systemName: "chevron.forward")
.foregroundColor(.element.tertiaryContent)
}
}
.listRowInsets(listRowInsets)
.listRowSeparator(.hidden)
.foregroundColor(.element.primaryContent)
}
}
@@ -18,16 +18,17 @@ import SwiftUI
/// An image that is styled for use as the screen icon in the onboarding flow.
struct AuthenticationIconImage: View {
let image: ImageAsset
let image: Image
var body: some View {
Image(image.name)
image
.resizable()
.renderingMode(.template)
.foregroundColor(.element.accent)
.frame(width: 90, height: 90)
.background(.white, in: Circle().inset(by: 2))
.foregroundColor(.element.secondaryContent)
.accessibilityHidden(true)
.padding(20)
.frame(width: 72, height: 72)
.background(RoundedRectangle(cornerRadius: 14).fill(Color.element.quinaryContent))
}
}
@@ -35,6 +36,6 @@ struct AuthenticationIconImage: View {
struct AuthenticationIconImage_Previews: PreviewProvider {
static var previews: some View {
AuthenticationIconImage(image: Asset.Images.serverSelectionIcon)
AuthenticationIconImage(image: Image(asset: Asset.Images.serverSelectionIcon))
}
}
@@ -197,13 +197,11 @@ final class LoginCoordinator: CoordinatorProtocol {
/// Presents the server selection screen as a modal.
private func presentServerSelectionScreen() {
let serverSelectionNavigationStackCoordinator = NavigationStackCoordinator()
let userNotificationController = UserNotificationController(rootCoordinator: serverSelectionNavigationStackCoordinator)
let userNotificationController = UserNotificationController(rootCoordinator: navigationStackCoordinator)
let parameters = ServerSelectionCoordinatorParameters(authenticationService: authenticationService,
userNotificationController: userNotificationController,
isModallyPresented: true)
isModallyPresented: false)
let coordinator = ServerSelectionCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] action in
@@ -211,9 +209,7 @@ final class LoginCoordinator: CoordinatorProtocol {
self.serverSelectionCoordinator(coordinator, didCompleteWith: action)
}
serverSelectionNavigationStackCoordinator.setRootCoordinator(coordinator)
navigationStackCoordinator.setSheetCoordinator(userNotificationController)
navigationStackCoordinator.push(coordinator)
}
/// Handles the result from the server selection modal, dismissing it after updating the view.
@@ -223,7 +219,7 @@ final class LoginCoordinator: CoordinatorProtocol {
updateViewModel()
}
navigationStackCoordinator.setSheetCoordinator(nil)
navigationStackCoordinator.pop()
}
/// Shows the forgot password screen.
@@ -29,15 +29,10 @@ struct LoginScreen: View {
VStack(spacing: 0) {
header
.padding(.top, UIConstants.topPaddingToNavigationBar)
.padding(.bottom, 36)
.padding(.bottom, 32)
serverInfo
.padding(.leading, 12)
Rectangle()
.fill(Color.element.quinaryContent)
.frame(height: 1)
.padding(.vertical, 21)
.padding(.bottom, 32)
switch context.viewState.loginMode {
case .password:
@@ -59,7 +54,7 @@ struct LoginScreen: View {
/// The header containing a Welcome Back title.
var header: some View {
Text(ElementL10n.ftueAuthWelcomeBackTitle)
.font(.element.title2Bold)
.font(.element.title1Bold)
.multilineTextAlignment(.center)
.foregroundColor(.element.primaryContent)
}
@@ -73,7 +68,13 @@ struct LoginScreen: View {
/// The form with text fields for username and password, along with a submit button.
var loginForm: some View {
VStack(spacing: 14) {
VStack(alignment: .leading, spacing: 0) {
Text(ElementL10n.ftueAuthSignInEnterDetails)
.font(.element.subheadline)
.foregroundColor(.element.primaryContent)
.padding(.horizontal, 16)
.padding(.bottom, 8)
TextField(ElementL10n.loginSigninUsernameHint, text: $context.username)
.focused($isUsernameFocused)
.textFieldStyle(.elementInput())
@@ -84,8 +85,7 @@ struct LoginScreen: View {
.onChange(of: isUsernameFocused, perform: usernameFocusChanged)
.onSubmit { isPasswordFocused = true }
.accessibilityIdentifier("usernameTextField")
Spacer().frame(height: 20)
.padding(.bottom, 20)
SecureField(ElementL10n.loginSignupPasswordHint, text: $context.password)
.focused($isPasswordFocused)
@@ -95,15 +95,18 @@ struct LoginScreen: View {
.onSubmit(submit)
.accessibilityIdentifier("passwordTextField")
Button { context.send(viewAction: .forgotPassword) } label: {
Text(ElementL10n.ftueAuthForgotPassword)
.font(.element.body)
}
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.bottom, 8)
// uncomment this piece of code once forgot password will be available
// Button { context.send(viewAction: .forgotPassword) } label: {
// Text(ElementL10n.ftueAuthForgotPassword)
// .font(.element.body)
// }
// .frame(maxWidth: .infinity, alignment: .trailing)
// .padding(.bottom, 8)
Spacer().frame(height: 32)
Button(action: submit) {
Text(ElementL10n.loginSignupSubmit)
Text(ElementL10n.loginContinue)
}
.buttonStyle(.elementAction(.xLarge))
.disabled(!context.viewState.canSubmit)
@@ -29,25 +29,36 @@ struct LoginServerInfoSection: View {
// MARK: - Views
var body: some View {
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 8) {
Text(ElementL10n.ftueAuthSignInChooseServerHeader)
.font(.element.subheadline)
.foregroundColor(.element.secondaryContent)
.foregroundColor(.element.primaryContent)
.padding(.horizontal, 16)
HStack {
Text(address)
.font(.element.body)
.foregroundColor(.element.primaryContent)
Spacer()
Button(action: editAction) {
Text(ElementL10n.edit)
.padding(.vertical, 2)
Button(action: editAction) {
HStack {
Text(address)
.font(.element.subheadlineBold)
.foregroundColor(.element.primaryContent)
.padding(.horizontal, 16)
.padding(.vertical)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.element.tertiaryContent)
.padding(.trailing, 16)
}
.buttonStyle(.elementGhost())
.accessibilityIdentifier("editServerButton")
.background(RoundedRectangle(cornerRadius: 14).fill(Color.element.system))
}
.accessibilityIdentifier("editServerButton")
}
}
}
struct LoginServerInfoSection_Previews: PreviewProvider {
static var previews: some View {
LoginServerInfoSection(address: "matrix.org", editAction: { })
.padding()
}
}
@@ -38,7 +38,7 @@ struct ServerSelectionViewState: BindableState {
/// The title shown on the confirm button.
var buttonTitle: String {
isModallyPresented ? ElementL10n.actionConfirm : ElementL10n.actionNext
isModallyPresented ? ElementL10n.continue : ElementL10n.actionNext
}
/// The text field is showing an error.
@@ -40,7 +40,7 @@ struct ServerSelectionScreen: View {
/// The title, message and icon at the top of the screen.
var header: some View {
VStack(spacing: 8) {
AuthenticationIconImage(image: Asset.Images.serverSelectionIcon)
AuthenticationIconImage(image: Image(asset: Asset.Images.serverSelectionIcon))
.padding(.bottom, 8)
Text(ElementL10n.ftueAuthChooseServerTitle)
@@ -51,13 +51,14 @@ struct ServerSelectionScreen: View {
Text(ElementL10n.ftueAuthChooseServerSubtitle)
.font(.element.body)
.multilineTextAlignment(.center)
.foregroundColor(.element.secondaryContent)
.foregroundColor(.element.tertiaryContent)
}
.padding(.horizontal, 16)
}
/// The text field and confirm button where the user enters a server URL.
var serverForm: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 24) {
TextField(ElementL10n.ftueAuthChooseServerEntryHint, text: $context.homeserverAddress)
.textFieldStyle(.elementInput(labelText: ElementL10n.hsUrl,
footerText: context.viewState.footerMessage,
@@ -70,8 +71,6 @@ struct ServerSelectionScreen: View {
.onSubmit(submit)
.accessibilityIdentifier("addressTextField")
Divider()
TextField(ElementL10n.ftueAuthChooseServerEntryHint, text: $context.slidingSyncProxyAddress)
.textFieldStyle(.elementInput(labelText: "Sliding sync proxy URL"))
.keyboardType(.URL)
@@ -80,6 +79,7 @@ struct ServerSelectionScreen: View {
.submitLabel(.done)
.onSubmit(submit)
.accessibilityIdentifier("slidingSyncProxyAddressTextField")
.padding(.bottom, 8)
Button(action: submit) {
Text(context.viewState.buttonTitle)
@@ -38,6 +38,5 @@ struct BugReportViewStateBindings {
enum BugReportViewAction {
case cancel
case submit
case toggleSendLogs
case removeScreenshot
}
@@ -41,8 +41,6 @@ class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
callback?(.cancel)
case .submit:
await submitBugReport()
case .toggleSendLogs:
context.sendingLogsEnabled.toggle()
case .removeScreenshot:
state.screenshot = nil
}
@@ -26,93 +26,81 @@ struct BugReportScreen: View {
@ObservedObject var context: BugReportViewModel.Context
var body: some View {
GeometryReader { geometry in
VStack {
ScrollView {
mainContent
.padding(.top, 50)
.padding(.horizontal, horizontalPadding)
}
.introspectScrollView { scrollView in
scrollView.keyboardDismissMode = .onDrag
}
buttons
.padding(.horizontal, horizontalPadding)
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16)
}
.navigationTitle(ElementL10n.titleActivityBugReport)
.toolbar {
if context.viewState.isModallyPresented {
ToolbarItem(placement: .cancellationAction) {
Button(ElementL10n.actionCancel) {
context.send(viewAction: .cancel)
}
}
}
}
.interactiveDismissDisabled()
ScrollView {
mainContent
.padding(.top, 50)
.padding(.horizontal, horizontalPadding)
}
.scrollDismissesKeyboard(.immediately)
.background(Color.element.system)
.navigationTitle(ElementL10n.bugReportScreenTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.interactiveDismissDisabled()
}
/// The main content of the view to be shown in a scroll view.
var mainContent: some View {
VStack(alignment: .leading, spacing: 12) {
Text(ElementL10n.sendBugReportDescription)
.accessibilityIdentifier("reportBugDescription")
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color.element.system)
if context.reportText.isEmpty {
Text(ElementL10n.sendBugReportPlaceholder)
.foregroundColor(Color.element.secondaryContent)
.padding(.horizontal, 8)
.padding(.vertical, 12)
}
TextEditor(text: $context.reportText)
.padding(4)
.background(Color.clear)
.cornerRadius(8)
.accessibilityIdentifier("reportTextView")
.introspectTextView { textView in
textView.backgroundColor = .clear
}
}
.frame(maxWidth: .infinity)
.frame(height: 300)
.font(.body)
Text(ElementL10n.sendBugReportLogsDescription)
.accessibilityIdentifier("sendLogsDescription")
HStack(spacing: 12) {
Toggle(ElementL10n.sendBugReportIncludeLogs, isOn: $context.sendingLogsEnabled)
.toggleStyle(ElementToggleStyle())
.accessibilityIdentifier("sendLogsToggle")
Text(ElementL10n.sendBugReportIncludeLogs)
.accessibilityIdentifier("sendLogsText")
}
.onTapGesture {
context.send(viewAction: .toggleSendLogs)
}
VStack(alignment: .leading, spacing: 24) {
descriptionTextEditor
sendLogsToggle
screenshot
}
}
/// The action buttons shown at the bottom of the view.
var buttons: some View {
VStack {
Button { context.send(viewAction: .submit) } label: {
Text(ElementL10n.actionSend)
@ViewBuilder
private var descriptionTextEditor: some View {
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.element.background)
TextEditor(text: $context.reportText)
.tint(.element.brand)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.clear)
.cornerRadius(14)
.accessibilityIdentifier("reportTextView")
.introspectTextView { textView in
textView.backgroundColor = .clear
}
if context.reportText.isEmpty {
Text(ElementL10n.bugReportScreenDescription)
.font(.element.body)
.foregroundColor(Color.element.secondaryContent)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.allowsHitTesting(false)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.disabled(context.reportText.count < 5)
.accessibilityIdentifier("sendButton")
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color.element.quaternaryContent)
}
.frame(maxWidth: .infinity)
.frame(height: 300)
.font(.body)
}
@ViewBuilder
private var sendLogsToggle: some View {
VStack(spacing: 8) {
Toggle(ElementL10n.bugReportScreenIncludeLogs, isOn: $context.sendingLogsEnabled)
.tint(Color.element.brand)
.accessibilityIdentifier("sendLogsToggle")
.padding(.horizontal, 16)
.padding(.vertical, 11)
.background(RoundedRectangle(cornerRadius: 14).fill(Color.element.background))
Text(ElementL10n.bugReportScreenLogsDescription)
.font(.element.caption1)
.foregroundColor(Color.element.secondaryContent)
.padding(.horizontal, -8)
}
}
@ViewBuilder
var screenshot: some View {
private var screenshot: some View {
if let screenshot = context.viewState.screenshot {
ZStack(alignment: .topTrailing) {
Image(uiImage: screenshot)
@@ -126,7 +114,27 @@ struct BugReportScreen: View {
.offset(x: 10, y: -10)
.accessibilityIdentifier("removeScreenshotButton")
}
.padding(.vertical, 10)
.padding(.vertical, 16)
.padding(.horizontal, 16)
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
if context.viewState.isModallyPresented {
ToolbarItem(placement: .cancellationAction) {
Button(ElementL10n.actionCancel) {
context.send(viewAction: .cancel)
}
}
}
ToolbarItem(placement: .confirmationAction) {
Button(ElementL10n.actionSend) {
context.send(viewAction: .submit)
}
.disabled(context.reportText.count < 5)
.accessibilityIdentifier("sendButton")
}
}
}
@@ -60,23 +60,10 @@ struct OnboardingViewState: BindableState {
}
init() {
// The pun doesn't translate, so we only use it for English.
let locale = Locale.current
let page4Title = locale.identifier.hasPrefix("en") ? "Cut the slack from teams." : ElementL10n.ftueAuthCarouselWorkplaceTitle
content = [
OnboardingPageContent(title: ElementL10n.ftueAuthCarouselSecureTitle.tinting("."),
message: ElementL10n.ftueAuthCarouselSecureBody,
image: Asset.Images.onboardingScreenPage1),
OnboardingPageContent(title: ElementL10n.ftueAuthCarouselControlTitle.tinting("."),
message: ElementL10n.ftueAuthCarouselControlBody,
image: Asset.Images.onboardingScreenPage2),
OnboardingPageContent(title: ElementL10n.ftueAuthCarouselEncryptedTitle.tinting("."),
message: ElementL10n.ftueAuthCarouselEncryptedBody,
image: Asset.Images.onboardingScreenPage3),
OnboardingPageContent(title: page4Title.tinting("."),
message: ElementL10n.ftueAuthCarouselWorkplaceBody(InfoPlistReader.target.bundleDisplayName),
image: Asset.Images.onboardingScreenPage4)
OnboardingPageContent(title: ElementL10n.ftueAuthCarouselWelcomeTitle.tinting(".", color: .element.accent),
message: ElementL10n.ftueAuthCarouselWelcomeBody(InfoPlistReader.target.bundleDisplayName),
image: Asset.Images.onboardingAppLogo)
]
bindings = OnboardingBindings()
}
@@ -0,0 +1,68 @@
//
// Copyright 2023 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 DesignKit
import SwiftUI
/// A glossy animated background view designed for the onboarding screen
struct OnboardingBackgroundView: View {
@Environment(\.colorScheme) private var colorScheme
@State private var factor = 0.0
@State private var isReversed = false
private let step = 0.001
private let timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()
var body: some View {
GeometryReader { geometry in
ZStack {
Image(asset: Asset.Images.onboardingBackgroundPart1)
.position(x: geometry.size.width * 0.125 - factor * geometry.size.width * 0.25, y: (1.0 - factor) * geometry.size.height * 0.55)
Image(asset: Asset.Images.onboardingBackgroundPart2)
.position(x: geometry.size.width * 1.05, y: factor * geometry.size.height * 0.45)
Image(asset: Asset.Images.onboardingBackgroundPart3)
.position(x: factor * geometry.size.width, y: geometry.size.height * 1.05 - factor * geometry.size.height * 0.08)
}
.frame(width: geometry.size.width, height: geometry.size.height)
.onReceive(timer) { _ in
if isReversed {
guard factor > 0 else {
isReversed = false
factor = step
return
}
factor -= step
} else {
guard factor < 1 else {
isReversed = true
factor = 1 - step
return
}
factor += 0.001
}
}
}
}
}
struct OnboardingBackgroundView_Previews: PreviewProvider {
static var previews: some View {
OnboardingBackgroundView()
}
}
@@ -20,27 +20,38 @@ struct OnboardingPageView: View {
/// The content that this page should display.
let content: OnboardingPageContent
@Environment(\.verticalSizeClass) private var verticalSizeClass
var body: some View {
VStack {
Image(content.image.name)
.resizable()
.scaledToFit()
.frame(maxWidth: 310) // This value is problematic. 300 results in dropped frames
// on iPhone 12/13 Mini. 305 the same on iPhone 12/13. As of
// iOS 15, 310 seems fine on all supported screen widths 🤞.
.padding(20)
.accessibilityHidden(true)
if verticalSizeClass == .regular {
Spacer()
Image(content.image.name)
.resizable()
.scaledToFit()
.padding(60)
.accessibilityHidden(true)
}
Spacer()
VStack(spacing: 8) {
Spacer()
Text(content.title)
.font(.element.title2Bold)
.font(.element.title1Bold)
.foregroundColor(.element.primaryContent)
.multilineTextAlignment(.center)
Text(content.message)
.font(.element.body)
.foregroundColor(.element.secondaryContent)
.multilineTextAlignment(.center)
}
.padding()
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
.padding(.bottom)
.padding(.horizontal, 16)
@@ -21,7 +21,8 @@ import SwiftUI
struct OnboardingScreen: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.layoutDirection) private var layoutDirection
@Environment(\.verticalSizeClass) private var verticalSizeClass
private var isLeftToRight: Bool { layoutDirection == .leftToRight }
private var pageCount: Int { context.viewState.content.count }
@@ -33,64 +34,68 @@ struct OnboardingScreen: View {
@ObservedObject var context: OnboardingViewModel.Context
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading) {
Spacer()
.frame(height: UIConstants.spacerHeight(in: geometry))
// The main content of the carousel
HStack(alignment: .top, spacing: 0) {
// Add a hidden page at the start of the carousel duplicating the content of the last page
OnboardingPageView(content: context.viewState.content[pageCount - 1])
.frame(width: geometry.size.width)
.accessibilityIdentifier("hiddenPage")
ForEach(0..<pageCount, id: \.self) { index in
OnboardingPageView(content: context.viewState.content[index])
ZStack {
OnboardingBackgroundView()
GeometryReader { geometry in
ZStack {
VStack(alignment: .leading) {
Spacer()
.frame(height: UIConstants.spacerHeight(in: geometry))
// The main content of the carousel
HStack(alignment: .top, spacing: 0) {
// Add a hidden page at the start of the carousel duplicating the content of the last page
OnboardingPageView(content: context.viewState.content[pageCount - 1])
.frame(width: geometry.size.width)
.accessibilityIdentifier("hiddenPage")
ForEach(0..<pageCount, id: \.self) { index in
OnboardingPageView(content: context.viewState.content[index])
.frame(width: geometry.size.width)
}
}
.offset(x: pageOffset(in: geometry))
Spacer()
if pageCount > 1 {
OnboardingPageIndicator(pageCount: pageCount, pageIndex: context.pageIndex)
.frame(width: geometry.size.width)
.padding(.bottom)
}
buttons
.frame(width: geometry.size.width)
.padding(.bottom, UIConstants.actionButtonBottomPadding)
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16)
Spacer()
.frame(height: UIConstants.spacerHeight(in: geometry))
}
.frame(maxHeight: .infinity)
.gesture(
DragGesture()
.onChanged(handleDragGestureChange)
.onEnded { handleDragGestureEnded($0, viewSize: geometry.size) }
)
}
.offset(x: pageOffset(in: geometry))
Spacer()
OnboardingPageIndicator(pageCount: pageCount, pageIndex: context.pageIndex)
.frame(width: geometry.size.width)
.padding(.bottom)
Spacer()
buttons
.frame(width: geometry.size.width)
.padding(.bottom, UIConstants.actionButtonBottomPadding)
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16)
Spacer()
.frame(height: UIConstants.spacerHeight(in: geometry))
}
.frame(maxHeight: .infinity)
.background(background.ignoresSafeArea().offset(x: pageOffset(in: geometry)))
.gesture(
DragGesture()
.onChanged(handleDragGestureChange)
.onEnded { handleDragGestureEnded($0, viewSize: geometry.size) }
)
.navigationBarHidden(true)
.onAppear(perform: startTimer)
.onDisappear(perform: stopTimer)
}
.navigationBarHidden(true)
.onAppear(perform: startTimer)
.onDisappear(perform: stopTimer)
}
/// The main action buttons.
var buttons: some View {
VStack(spacing: 12) {
Button { context.send(viewAction: .login) } label: {
Text(ElementL10n.loginSplashSubmit)
Text(ElementL10n.loginContinue)
}
.buttonStyle(.elementAction(.xLarge))
.accessibilityIdentifier("getStartedButton")
}
.padding(.horizontal, 16)
.padding(.horizontal, verticalSizeClass == .compact ? 128 : 24)
.readableFrame()
}
@@ -111,7 +116,7 @@ struct OnboardingScreen: View {
/// Starts the animation timer for an automatic carousel effect.
private func startTimer() {
guard pageTimer == nil else { return }
guard pageTimer == nil, pageCount > 1 else { return }
pageTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
if context.pageIndex == pageCount - 1 {
@@ -30,6 +30,7 @@ enum RoomDetailsViewModelAction {
struct RoomDetailsViewState: BindableState {
let roomId: String
let canonicalAlias: String?
let isEncrypted: Bool
let isDirect: Bool
var title = ""
@@ -56,6 +57,8 @@ enum RoomDetailsErrorType: Hashable {
enum RoomDetailsViewAction {
case processTapPeople
case copyRoomLink
case inviteToRoom
}
struct RoomDetailsMember: Identifiable, Equatable {
@@ -27,8 +27,10 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc
var callback: ((RoomDetailsViewModelAction) -> Void)?
init(roomProxy: RoomProxyProtocol, mediaProvider: MediaProviderProtocol) {
init(roomProxy: RoomProxyProtocol,
mediaProvider: MediaProviderProtocol) {
super.init(initialViewState: .init(roomId: roomProxy.id,
canonicalAlias: roomProxy.canonicalAlias,
isEncrypted: roomProxy.isEncrypted,
isDirect: roomProxy.isDirect,
title: roomProxy.displayName ?? roomProxy.name ?? "Unknown Room",
@@ -37,7 +39,7 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc
members: [],
bindings: .init()),
imageProvider: mediaProvider)
Task {
switch await roomProxy.members() {
case .success(let members):
@@ -55,6 +57,21 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc
switch viewAction {
case .processTapPeople:
callback?(.requestMemberDetailsPresentation(members))
case .copyRoomLink:
copyRoomLink()
case .inviteToRoom:
inviteToRoom()
}
}
// MARK: - Private
private func copyRoomLink() {
// TODO: to be implemented
// ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: ElementL10n.linkCopiedToClipboard))
}
private func inviteToRoom() {
// TODO: to be implemented
}
}
@@ -37,96 +37,121 @@ struct RoomDetailsScreen: View {
}
}
.alert(item: $context.alertInfo) { $0.alert }
.navigationTitle(ElementL10n.roomDetailsTitle)
}
// MARK: - Private
private var headerSection: some View {
VStack(spacing: 16.0) {
VStack(spacing: 8.0) {
LoadableAvatarImage(url: context.viewState.avatarURL,
name: context.viewState.title,
contentID: context.viewState.roomId,
avatarSize: .room(on: .details),
imageProvider: context.imageProvider)
.accessibilityIdentifier("roomAvatarImage")
Text(context.viewState.title)
.foregroundColor(.element.primaryContent)
.font(.element.headline)
.font(.element.title1Bold)
.multilineTextAlignment(.center)
if let canonicalAlias = context.viewState.canonicalAlias {
Text(canonicalAlias)
.foregroundColor(.element.secondaryContent)
.font(.element.body)
.multilineTextAlignment(.center)
}
// TODO: uncomment this code to display copy link and invite buttons
// HStack(spacing: 32) {
// SettingsActionButton(title: ElementL10n.roomDetailsCopyLink, image: Image(systemName: "link")) {
// context.send(viewAction: .copyRoomLink)
// }
// SettingsActionButton(title: ElementL10n.inviteUsersToRoomActionInvite.capitalized, image: Image(systemName: "square.and.arrow.up")) {
// context.send(viewAction: .inviteToRoom)
// }
// }
// .padding(.top, 32)
}
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
.padding(.top, 18)
.padding(.bottom, 42)
}
private func topicSection(with topic: String) -> some View {
Section(ElementL10n.roomSettingsTopic) {
Text(topic)
.foregroundColor(.element.secondaryContent)
.font(.element.footnote)
}
Section(header: Text(ElementL10n.roomSettingsTopic)
.formSectionHeader()) {
Text(topic)
.foregroundColor(.element.secondaryContent)
.font(.element.footnote)
}
}
private var aboutSection: some View {
Section(ElementL10n.roomDetailsAboutSectionTitle) {
Button {
context.send(viewAction: .processTapPeople)
} label: {
HStack {
Image(systemName: "person")
.foregroundColor(.element.systemGray)
.padding(4)
.background(Color.element.systemGray6)
.clipShape(Circle())
.frame(width: menuIconSize, height: menuIconSize)
Text(ElementL10n.bottomActionPeople)
.foregroundColor(.element.primaryContent)
.font(.body)
Spacer()
Section(header: Text(ElementL10n.roomDetailsAboutSectionTitle)
.formSectionHeader()) {
Button {
context.send(viewAction: .processTapPeople)
} label: {
HStack {
Image(systemName: "person")
.foregroundColor(.element.secondaryContent)
.padding(4)
.background(Color.element.system)
.clipShape(RoundedCornerShape(radius: 8, corners: .allCorners))
.frame(width: menuIconSize, height: menuIconSize)
if context.viewState.isLoadingMembers {
ProgressView()
} else {
Text(String(context.viewState.members.count))
.foregroundColor(.element.secondaryContent)
.font(.element.body)
Image(systemName: "chevron.forward")
.foregroundColor(.element.secondaryContent)
Text(ElementL10n.bottomActionPeople)
.foregroundColor(.element.primaryContent)
.font(.body)
Spacer()
if context.viewState.isLoadingMembers {
ProgressView()
} else {
Text(String(context.viewState.members.count))
.foregroundColor(.element.secondaryContent)
.font(.element.body)
Image(systemName: "chevron.forward")
.foregroundColor(.element.quaternaryContent)
}
}
}
.listRowInsets(listRowInsets)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("peopleButton")
.disabled(context.viewState.isLoadingMembers)
}
.listRowInsets(listRowInsets)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("peopleButton")
.disabled(context.viewState.isLoadingMembers)
}
}
private var securitySection: some View {
Section(ElementL10n.roomProfileSectionSecurity) {
HStack(alignment: .center) {
Image(systemName: "lock.shield")
.foregroundColor(.element.systemGray)
.padding(4)
.background(Color.element.systemGray6)
.clipShape(Circle())
.frame(width: menuIconSize, height: menuIconSize)
VStack(alignment: .leading) {
Text(ElementL10n.encryptionEnabled)
.foregroundColor(.element.primaryContent)
.font(.element.body)
Text(ElementL10n.encryptionEnabledTileDescription)
Section(header: Text(ElementL10n.roomProfileSectionSecurity)
.formSectionHeader()) {
HStack(alignment: .top) {
Image(systemName: "lock.shield")
.foregroundColor(.element.secondaryContent)
.font(.element.footnote)
.padding(4)
.background(Color.element.system)
.clipShape(RoundedCornerShape(radius: 8, corners: .allCorners))
.frame(width: menuIconSize, height: menuIconSize)
VStack(alignment: .leading, spacing: 2) {
Text(ElementL10n.encryptionEnabled)
.foregroundColor(.element.primaryContent)
.font(.element.body)
Text(ElementL10n.encryptionEnabledTileDescription)
.foregroundColor(.element.secondaryContent)
.font(.element.footnote)
}
Spacer()
Image(systemName: "checkmark")
.foregroundColor(.element.quaternaryContent)
}
Spacer()
Image(systemName: "checkmark")
.foregroundColor(.element.secondaryContent)
.padding(.horizontal, -3)
}
}
}
}
@@ -144,6 +169,7 @@ struct RoomDetails_Previews: PreviewProvider {
topic: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
isDirect: false,
isEncrypted: true,
canonicalAlias: "#alias:domain.com",
members: members)
let viewModel = RoomDetailsViewModel(roomProxy: roomProxy,
mediaProvider: MockMediaProvider())
@@ -35,7 +35,7 @@ struct RoomMemberDetailsMemberCell: View {
.accessibilityHidden(true)
Text(member.name ?? "")
.font(.element.callout.bold())
.font(.element.calloutBold)
.foregroundColor(.element.primaryContent)
.lineLimit(1)
@@ -29,17 +29,18 @@ struct RoomMemberDetailsScreen: View {
RoomMemberDetailsMemberCell(member: member, context: context)
.id(member.id)
}
} footer: {
} header: {
Text(ElementL10n.roomTitleMembers(context.viewState.members.count))
.foregroundColor(.element.secondaryContent)
.font(.element.footnote)
.font(.element.body)
.padding(.vertical, 12)
}
.padding(.horizontal)
}
}
.searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always))
.alert(item: $context.alertInfo) { $0.alert }
.navigationTitle(ElementL10n.listMembers)
.navigationTitle(ElementL10n.bottomActionPeople)
}
}
@@ -31,15 +31,18 @@ struct SettingsScreen: View {
Form {
userSection
.listRowBackground(rowBackgroundColor)
analyticsSection
simplifiedSection
.listRowBackground(rowBackgroundColor)
userInterfaceSection
.listRowBackground(rowBackgroundColor)
// analyticsSection
// .listRowBackground(rowBackgroundColor)
logoutSection
.listRowBackground(rowBackgroundColor)
// userInterfaceSection
// .listRowBackground(rowBackgroundColor)
// logoutSection
// .listRowBackground(rowBackgroundColor)
}
.introspectTableView { tableView in
tableView.backgroundColor = .clear
@@ -59,7 +62,7 @@ struct SettingsScreen: View {
}
private var backgroundColor: Color {
.element.systemGray6
.element.system
}
private var rowBackgroundColor: Color {
@@ -78,14 +81,40 @@ struct SettingsScreen: View {
VStack(alignment: .leading, spacing: 4) {
Text(context.viewState.userDisplayName ?? "")
.font(.title3)
.foregroundColor(.element.primaryContent)
Text(context.viewState.userID)
.font(.subheadline)
.foregroundColor(.element.primaryContent)
}
}
.listRowInsets(listRowInsets)
}
}
private var simplifiedSection: some View {
Section {
SettingsDefaultRow(title: ElementL10n.sendBugReport,
image: Image(systemName: "questionmark.circle")) {
context.send(viewAction: .reportBug)
}
.accessibilityIdentifier("reportBugButton")
SettingsDefaultRow(title: ElementL10n.actionSignOut,
image: Image(systemName: "rectangle.portrait.and.arrow.right")) {
showingLogoutConfirmation = true
}
.accessibilityIdentifier("logoutButton")
.alert(ElementL10n.actionSignOut,
isPresented: $showingLogoutConfirmation) {
Button(ElementL10n.actionSignOut,
role: .destructive,
action: logout)
} message: {
Text(ElementL10n.actionSignOutConfirmationSimple)
}
}
}
private var analyticsSection: some View {
Section {
Button { context.send(viewAction: .reportBug) } label: {
@@ -157,13 +186,8 @@ struct SettingsScreen: View {
}
private var closeButton: some View {
Button(action: close) {
Image(systemName: "xmark")
.font(.title3.bold())
.foregroundColor(.element.secondaryContent)
.padding(4)
}
.accessibilityIdentifier("closeButton")
Button(ElementL10n.actionCancel, action: close)
.accessibilityIdentifier("closeButton")
}
private func close() {
@@ -274,6 +274,7 @@ class MockScreen: Identifiable {
return navigationSplitCoordinator
case .roomDetailsScreen:
let navigationStackCoordinator = NavigationStackCoordinator()
let userNotificationController = UserNotificationController(rootCoordinator: navigationStackCoordinator)
let coordinator = RoomDetailsCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: MockRoomProxy(displayName: "Room",
isEncrypted: true,