430 - Design review for first public TestFlight Part2 (#496)

* 430 - Design review for first public TestFlight Part2

---------

Co-authored-by: Doug <douglase@element.io>
This commit is contained in:
Gil Eluard
2023-01-30 12:51:07 +01:00
committed by GitHub
parent f29c0a9811
commit e2e69534e3
19 changed files with 412 additions and 192 deletions
@@ -25,8 +25,9 @@ struct AuthenticationIconImage: View {
.resizable()
.renderingMode(.template)
.foregroundColor(.element.secondaryContent)
.aspectRatio(contentMode: .fit)
.accessibilityHidden(true)
.padding(20)
.padding(16)
.frame(width: 72, height: 72)
.background(RoundedRectangle(cornerRadius: 14).fill(Color.element.quinaryContent))
}
@@ -76,6 +76,7 @@ struct HomeScreen: View {
userMenuButton
}
}
.background(Color.element.background)
}
@ViewBuilder
@@ -83,15 +84,15 @@ struct HomeScreen: View {
Menu {
Section {
Button(action: settings) {
Label(ElementL10n.settingsUserSettings, systemImage: "gearshape")
Label(ElementL10n.settings, systemImage: "gearshape")
}
}
Section {
Button(action: inviteFriends) {
Label(ElementL10n.inviteFriends, systemImage: "square.and.arrow.up")
Label(ElementL10n.actionInvite, systemImage: "square.and.arrow.up")
}
Button(action: feedback) {
Label(ElementL10n.feedback, systemImage: "questionmark.circle")
Label(ElementL10n.bugReportScreenTitle, systemImage: "questionmark.circle")
}
}
Section {
@@ -122,31 +123,35 @@ struct HomeScreen: View {
private var sessionVerificationBanner: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 2) {
Text(ElementL10n.sessionVerificationBannerTitle)
.font(.element.subheadlineBold)
.foregroundColor(.element.systemPrimaryLabel)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 16) {
Text(ElementL10n.sessionVerificationBannerTitle)
.font(.element.headline)
.foregroundColor(.element.systemPrimaryLabel)
Spacer()
Button {
context.send(viewAction: .skipSessionVerification)
} label: {
Image(systemName: "xmark")
.foregroundColor(.element.secondaryContent)
.frame(width: 12, height: 12)
}
}
Text(ElementL10n.sessionVerificationBannerMessage)
.font(.element.footnote)
.foregroundColor(.element.systemSecondaryLabel)
.font(.element.subheadline)
.foregroundColor(.element.secondaryContent)
}
HStack(spacing: 16) {
Button(ElementL10n.actionSkip) {
context.send(viewAction: .skipSessionVerification)
}
.frame(maxWidth: .infinity)
.buttonStyle(.elementCapsule)
Button(ElementL10n.continue) {
context.send(viewAction: .verifySession)
}
.frame(maxWidth: .infinity)
.buttonStyle(.elementCapsuleProminent)
Button(ElementL10n.continue) {
context.send(viewAction: .verifySession)
}
.frame(maxWidth: .infinity)
.buttonStyle(.elementCapsuleProminent)
}
.padding(16)
.background(Color.element.systemSecondaryBackground)
.background(Color.element.system)
.cornerRadius(14)
.padding(.horizontal, 16)
}
@@ -34,11 +34,17 @@ struct HomeScreenRoomCell: View {
footer
}
}
.frame(minHeight: 64.0)
.frame(minHeight: 84.0)
.accessibilityElement(children: .combine)
}
.buttonStyle(HomeScreenRoomCellButtonStyle())
.accessibilityIdentifier("roomName:\(room.name)")
.overlay(alignment: .bottom) {
Divider()
.frame(height: 0.5)
.background(Color.element.quinaryContent)
.padding(.leading, 84)
}
}
@ViewBuilder
@@ -53,17 +59,17 @@ struct HomeScreenRoomCell: View {
@ViewBuilder
var header: some View {
HStack(alignment: .firstTextBaseline) {
HStack(alignment: .top, spacing: 16) {
Text(room.name)
.font(.element.callout.bold())
.font(.element.headline)
.foregroundColor(.element.primaryContent)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
if let timestamp = room.timestamp {
Text(timestamp)
.font(.element.caption1)
.foregroundColor(.element.secondaryContent)
.font(.element.footnote)
.foregroundColor(room.hasUnreads ? .element.brand : .element.tertiaryContent)
.id(timestamp)
.transition(.opacity.animation(.elementDefault))
}
@@ -91,9 +97,15 @@ struct HomeScreenRoomCell: View {
if room.hasUnreads {
Rectangle()
.frame(width: 12, height: 12)
.foregroundColor(.element.primaryContent)
.foregroundColor(.element.brand)
.clipShape(Circle())
.transition(.opacity.animation(.elementDefault))
.padding(.leading, 12)
} else {
// Force extra padding between last message text and the right border of the screen if there is no unread dot
Rectangle()
.fill(Color.clear)
.frame(width: 12, height: 12)
}
}
.animation(.elementDefault, value: room)
@@ -111,18 +123,16 @@ struct HomeScreenRoomCellButtonStyle: ButtonStyle {
private extension View {
func lastMessageFormatting() -> some View {
font(.element.subheadline)
.foregroundColor(.element.secondaryContent)
.foregroundColor(.element.tertiaryContent)
.lineLimit(2)
.multilineTextAlignment(.leading)
.padding(.top, 2)
}
// To be used to indicate the selected room too
func roomCellBackground(_ background: Color) -> some View {
padding(.horizontal, 8)
.padding(.vertical, 6)
.background { background.clipShape(RoundedRectangle(cornerRadius: 12)) }
.padding(.horizontal, 8)
.background { background }
}
}
@@ -20,7 +20,8 @@ struct SplashScreenCoordinator: CoordinatorProtocol {
func toPresentable() -> AnyView {
AnyView(
ZStack {
Image(asset: Asset.Images.appLogo)
Image(asset: Asset.Images.onboardingAppLogo)
.renderingMode(.original)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
)
@@ -22,13 +22,40 @@ enum SessionVerificationViewModelAction {
struct SessionVerificationViewState: BindableState {
var verificationState: SessionVerificationStateMachine.State = .initial
var title: String? {
switch verificationState {
case .initial:
return ElementL10n.verificationOpenOtherToVerifyTitle
case .requestingVerification:
return ElementL10n.verificationRequestWaitingAcceptRequestTitle
case .verificationRequestAccepted:
return ElementL10n.verificationCompareEmojisTitle
case .startingSasVerification:
return nil
case .sasVerificationStarted:
return nil
case .showingChallenge:
return ElementL10n.verificationCompareEmojisTitle
case .acceptingChallenge:
return ElementL10n.verificationCompareEmojisTitle
case .decliningChallenge:
return nil
case .verified:
return ElementL10n.verificationConclusionOkSelfNoticeTitle
case .cancelling:
return nil
case .cancelled:
return ElementL10n.verificationCancelled
}
}
var message: String {
switch verificationState {
case .initial:
return ElementL10n.verificationOpenOtherToVerify
return ElementL10n.verificationOpenOtherToVerifyDetail
case .requestingVerification:
return ElementL10n.verificationRequestWaiting
return ElementL10n.verificationRequestWaitingAcceptRequestDetail
case .verificationRequestAccepted:
return ElementL10n.verificationEmojiNotice
case .startingSasVerification:
@@ -36,17 +63,17 @@ struct SessionVerificationViewState: BindableState {
case .sasVerificationStarted:
return ElementL10n.verificationRequestWaiting
case .acceptingChallenge:
return ElementL10n.verificationRequestWaiting
return ElementL10n.verificationCompareEmojisDetail
case .decliningChallenge:
return ElementL10n.verificationRequestWaiting
case .cancelling:
return ElementL10n.verificationRequestWaiting
case .showingChallenge:
return ElementL10n.verificationCodeNotice
return ElementL10n.verificationCompareEmojisDetail
case .verified:
return ElementL10n.verificationConclusionOkSelfNotice
case .cancelled:
return ElementL10n.verificationCancelled
return ElementL10n.verificationRequestCancelledDetail
}
}
}
@@ -106,6 +106,12 @@ class SessionVerificationViewModel: SessionVerificationViewModelType, SessionVer
self.declineChallenge()
case (_, .cancel, .cancelling):
self.cancelVerification()
case (_, _, .verified):
// Dismiss the success screen automatically.
Task {
try await Task.sleep(for: .seconds(2))
self.callback?(.finished)
}
default:
break
}
@@ -22,23 +22,18 @@ struct SessionVerificationScreen: View {
var body: some View {
NavigationView {
ScrollView {
VStack(spacing: 32.0) {
Text(context.viewState.message)
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.element.systemPrimaryLabel)
.accessibilityIdentifier("titleLabel")
VStack(spacing: 32) {
screenHeader
Spacer()
mainContent
}
.padding()
.padding(.top, 64)
.padding(.horizontal, 16)
.padding(.top, 24)
.frame(maxWidth: .infinity)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(ElementL10n.verificationProfileVerify)
.toolbar { toolbarContent }
}
.background(Color.element.systemSecondaryBackground)
.background(Color.element.background)
.safeAreaInset(edge: .bottom) { actionButtons.padding() }
}
.navigationViewStyle(.stack)
@@ -46,52 +41,82 @@ struct SessionVerificationScreen: View {
// MARK: - Private
private var headerImageName: String {
switch context.viewState.verificationState {
case .initial:
return "macbook.and.iphone"
case .cancelled:
return "exclamationmark.shield"
case .requestingVerification:
return "hourglass"
case .verificationRequestAccepted:
return "face.smiling"
case .startingSasVerification:
return "hourglass"
case .sasVerificationStarted:
return "hourglass"
case .cancelling:
return "hourglass"
case .acceptingChallenge:
return "hourglass"
case .decliningChallenge:
return "hourglass"
case .showingChallenge:
return "face.smiling"
case .verified:
return "checkmark.shield"
}
}
@ViewBuilder
private var screenHeader: some View {
VStack(spacing: 0) {
AuthenticationIconImage(image: Image(systemName: headerImageName))
.padding(.bottom, 16)
Text(context.viewState.title ?? "")
.font(.title2.bold())
.multilineTextAlignment(.center)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("titleLabel")
.padding(.bottom, 8)
Text(context.viewState.message)
.font(.subheadline)
.multilineTextAlignment(.center)
.foregroundColor(.element.tertiaryContent)
.accessibilityIdentifier("detailLabel")
}
}
@ViewBuilder
private var mainContent: some View {
switch context.viewState.verificationState {
case .initial:
StateIcon(systemName: "lock.shield")
case .cancelled:
StateIcon(systemName: "xmark.shield")
.accessibilityIdentifier("sessionVerificationFailedIcon")
case .showingChallenge(let emojis):
emojisPanel(with: emojis)
case .acceptingChallenge(let emojis):
emojisPanel(with: emojis)
case .requestingVerification:
ProgressView()
.accessibilityIdentifier("requestingVerificationProgressView")
case .verificationRequestAccepted:
StateIcon(systemName: "lock.shield")
case .startingSasVerification:
ProgressView()
.accessibilityIdentifier("startingSasVerification")
case .sasVerificationStarted:
ProgressView()
.accessibilityIdentifier("startedSasVerification")
case .cancelling:
ProgressView()
.accessibilityIdentifier("cancellingVerificationProgressView")
case .acceptingChallenge:
ProgressView()
.accessibilityIdentifier("acceptingChallengeProgressView")
case .decliningChallenge:
ProgressView()
.accessibilityIdentifier("decliningChallengeProgressView")
case .showingChallenge(let emojis):
HStack(spacing: 16) {
ForEach(emojis.prefix(4), id: \.self) { emoji in
EmojiView(emoji: emoji)
}
.tint(.element.secondaryContent)
.scaleEffect(2)
default:
// In All other cases, we just want an empty view
EmptyView()
}
}
@ViewBuilder
private func emojisPanel(with emojis: [SessionVerificationEmoji]) -> some View {
HStack(spacing: 16) {
ForEach(emojis.prefix(4), id: \.self) { emoji in
EmojiView(emoji: emoji)
}
HStack(spacing: 16) {
ForEach(emojis.suffix(from: 4), id: \.self) { emoji in
EmojiView(emoji: emoji)
}
}
HStack(spacing: 16) {
ForEach(emojis.suffix(from: 4), id: \.self) { emoji in
EmojiView(emoji: emoji)
}
case .verified:
StateIcon(systemName: "checkmark.shield")
.accessibilityIdentifier("sessionVerificationSucceededIcon")
}
}
@@ -113,7 +138,7 @@ struct SessionVerificationScreen: View {
.accessibilityIdentifier("restartButton")
case .verificationRequestAccepted:
Button(ElementL10n.startVerification) {
Button(ElementL10n.sessionVerificationStart) {
context.send(viewAction: .startSasVerification)
}
.buttonStyle(.elementAction(.xLarge))
@@ -133,14 +158,28 @@ struct SessionVerificationScreen: View {
.font(.element.bodyBold)
.accessibilityIdentifier("challengeDeclineButton")
}
case .verified:
Button(ElementL10n.finish) {
context.send(viewAction: .close)
case .acceptingChallenge:
VStack(spacing: 30) {
Button { context.send(viewAction: .accept) } label: {
HStack(spacing: 16) {
ProgressView()
.tint(.element.background)
Label(ElementL10n.verificationSasMatch, systemImage: "checkmark")
}
}
.buttonStyle(.elementAction(.xLarge))
.accessibilityIdentifier("challengeAcceptButton")
.disabled(true)
Button(ElementL10n.verificationSasDoNotMatch) {
context.send(viewAction: .decline)
}
.font(.element.bodyBold)
.accessibilityIdentifier("challengeDeclineButton")
.disabled(true)
}
.buttonStyle(.elementAction(.xLarge))
.accessibilityIdentifier("finishButton")
default:
EmptyView()
}
@@ -149,11 +188,10 @@ struct SessionVerificationScreen: View {
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button { context.send(viewAction: .close) } label: {
Image(systemName: "xmark")
Button(ElementL10n.actionCancel) {
context.send(viewAction: .close)
}
.font(.element.bodyBold)
.foregroundColor(.element.systemSecondaryLabel)
.foregroundColor(.element.accent)
.accessibilityIdentifier("closeButton")
}
}
@@ -166,8 +204,8 @@ struct SessionVerificationScreen: View {
Text(emoji.symbol)
.font(.element.largeTitleBold)
Text(emoji.description)
.font(.element.caption2)
.foregroundColor(.element.systemSecondaryLabel)
.font(.element.subheadline)
.foregroundColor(.element.secondaryContent)
}
.padding(8.0)
}
@@ -50,6 +50,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
self.toggleAnalytics()
case .reportBug:
self.presentBugReportScreen()
case .sessionVerification:
self.verifySession()
case .logout:
self.callback?(.logout)
}
@@ -91,6 +93,23 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
parameters.navigationStackCoordinator.push(coordinator)
}
private func verifySession() {
guard let sessionVerificationController = parameters.userSession.sessionVerificationController else {
fatalError("The sessionVerificationController should aways be valid at this point")
}
let verificationParameters = SessionVerificationCoordinatorParameters(sessionVerificationControllerProxy: sessionVerificationController)
let coordinator = SessionVerificationCoordinator(parameters: verificationParameters)
coordinator.callback = { [weak self] in
self?.parameters.navigationStackCoordinator.setSheetCoordinator(nil)
}
parameters.navigationStackCoordinator.setSheetCoordinator(coordinator) { [weak self] in
self?.parameters.navigationStackCoordinator.setSheetCoordinator(nil)
}
}
private func showSuccess(label: String) {
parameters.userNotificationController.submitNotification(UserNotification(title: label))
@@ -21,6 +21,7 @@ enum SettingsScreenViewModelAction {
case close
case toggleAnalytics
case reportBug
case sessionVerification
case logout
}
@@ -30,6 +31,7 @@ struct SettingsScreenViewState: BindableState {
var userID: String
var userAvatarURL: URL?
var userDisplayName: String?
var showSessionVerificationSection: Bool
}
struct SettingsScreenViewStateBindings {
@@ -40,5 +42,6 @@ enum SettingsScreenViewAction {
case close
case toggleAnalytics
case reportBug
case sessionVerification
case logout
}
@@ -26,7 +26,10 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
init(withUserSession userSession: UserSessionProtocol) {
self.userSession = userSession
let bindings = SettingsScreenViewStateBindings()
super.init(initialViewState: .init(bindings: bindings, deviceID: userSession.deviceId, userID: userSession.userID),
super.init(initialViewState: .init(bindings: bindings,
deviceID: userSession.deviceId,
userID: userSession.userID,
showSessionVerificationSection: !(userSession.sessionVerificationController?.isVerified ?? false)),
imageProvider: userSession.mediaProvider)
Task {
@@ -40,6 +43,20 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
state.userDisplayName = userDisplayName
}
}
userSession.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
switch callback {
case .sessionVerificationNeeded:
self?.state.showSessionVerificationSection = true
case .didVerifySession:
self?.state.showSessionVerificationSection = false
default:
break
}
}
.store(in: &cancellables)
}
override func process(viewAction: SettingsScreenViewAction) async {
@@ -52,6 +69,8 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
callback?(.reportBug)
case .logout:
callback?(.logout)
case .sessionVerification:
callback?(.sessionVerification)
}
}
}
@@ -32,17 +32,16 @@ struct SettingsScreen: View {
userSection
.listRowBackground(rowBackgroundColor)
if context.viewState.showSessionVerificationSection {
sessionVerificationSection
.listRowBackground(rowBackgroundColor)
}
simplifiedSection
.listRowBackground(rowBackgroundColor)
// analyticsSection
// .listRowBackground(rowBackgroundColor)
// userInterfaceSection
// .listRowBackground(rowBackgroundColor)
// logoutSection
// .listRowBackground(rowBackgroundColor)
signOutSection
.listRowBackground(rowBackgroundColor)
}
.introspectTableView { tableView in
tableView.backgroundColor = .clear
@@ -80,10 +79,10 @@ struct SettingsScreen: View {
VStack(alignment: .leading, spacing: 4) {
Text(context.viewState.userDisplayName ?? "")
.font(.title3)
.font(.element.title3)
.foregroundColor(.element.primaryContent)
Text(context.viewState.userID)
.font(.subheadline)
.font(.element.subheadline)
.foregroundColor(.element.primaryContent)
}
}
@@ -91,14 +90,38 @@ struct SettingsScreen: View {
}
}
private var sessionVerificationSection: some View {
Section {
SettingsDefaultRow(title: ElementL10n.settingsSessionVerification,
image: Image(systemName: "checkmark.shield")) {
context.send(viewAction: .sessionVerification)
}
.accessibilityIdentifier("sessionVerificationButton")
}
}
private var simplifiedSection: some View {
Section {
SettingsPickerRow(title: ElementL10n.settingsTimelineStyle,
image: Image(systemName: "rectangle.grid.1x2"),
selection: $settings.timelineStyle) {
ForEach(TimelineStyle.allCases, id: \.self) { style in
Text(style.description)
.tag(style)
}
}
.accessibilityIdentifier("timelineStylePicker")
SettingsDefaultRow(title: ElementL10n.sendBugReport,
image: Image(systemName: "questionmark.circle")) {
context.send(viewAction: .reportBug)
}
.accessibilityIdentifier("reportBugButton")
}
}
private var signOutSection: some View {
Section {
SettingsDefaultRow(title: ElementL10n.actionSignOut,
image: Image(systemName: "rectangle.portrait.and.arrow.right")) {
showingLogoutConfirmation = true
@@ -112,76 +135,20 @@ struct SettingsScreen: View {
} message: {
Text(ElementL10n.actionSignOutConfirmationSimple)
}
}
}
private var analyticsSection: some View {
Section {
Button { context.send(viewAction: .reportBug) } label: {
HStack {
Text(ElementL10n.sendBugReport)
Spacer()
Image(systemName: "chevron.forward")
.foregroundColor(.element.tertiaryContent)
}
}
.listRowInsets(listRowInsets)
.listRowSeparator(.hidden)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("reportBugButton")
}
}
@ViewBuilder
private var userInterfaceSection: some View {
Section {
Picker(ElementL10n.settingsTimelineStyle, selection: $settings.timelineStyle) {
ForEach(TimelineStyle.allCases, id: \.self) { style in
Text(style.description)
.tag(style)
}
}
.listRowInsets(listRowInsets)
.accessibilityIdentifier("timelineStylePicker")
}
}
private var logoutSection: some View {
Section {
Button { showingLogoutConfirmation = true } label: {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
.foregroundColor(.element.systemGray)
.padding(4)
.background(Color.element.systemGray6)
.clipShape(Circle())
.frame(width: menuIconSize, height: menuIconSize)
Text(ElementL10n.actionSignOut)
Spacer()
Image(systemName: "chevron.forward")
.foregroundColor(.element.tertiaryContent)
}
}
.listRowInsets(listRowInsets)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("logoutButton")
.alert(ElementL10n.actionSignOut,
isPresented: $showingLogoutConfirmation) {
Button(ElementL10n.actionSignOut,
role: .destructive,
action: logout)
} message: {
Text(ElementL10n.actionSignOutConfirmationSimple)
}
} footer: {
VStack {
versionText
.font(.element.caption1)
.foregroundColor(.element.tertiaryContent)
.frame(maxWidth: .infinity)
if let deviceId = context.viewState.deviceID {
Text(deviceId)
.font(.element.caption1)
.foregroundColor(.element.tertiaryContent)
}
}
.padding(.top, 24)
}
}