mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Combine the Server Selection screen into the Server Confirmation screen and add autocompletion when entering an account provider. (#5949)
* Adjust login flow to exclusively use the ServerSelectionScreen * Move async code to server selection vm * ServerSelection supports pro picker options * Use homeserver address for both confirmation and picker modes * rename ServerSelectionScreenMode * remove ServerConfirmationScreen It's been superseded by the ServerSelectionScreen * rename userInput * Update strings with latest design strings * Add server input autocompletion * Store previous servers in AppSettings and autofill on server signin * Audit changes for logging * Enforce lowercase server history storage * Update tests * Update Xcode project * PR review fixups * Add HomeserverHistoryManager * Review fixups 2 * Review fixups 3
This commit is contained in:
+103
-43
@@ -12,74 +12,91 @@ import SwiftUI
|
||||
struct ServerSelectionScreen: View {
|
||||
@Bindable var context: ServerSelectionScreenViewModel.Context
|
||||
|
||||
private var backgroundColor: Color {
|
||||
switch context.viewState.mode {
|
||||
case .userInput: .compound.bgCanvasDefault
|
||||
case .picker: .compound.bgSubtleSecondaryLevel0
|
||||
}
|
||||
}
|
||||
|
||||
private var headerIconStyle: BigIcon.Style {
|
||||
switch context.viewState.mode {
|
||||
case .userInput: .defaultSolid
|
||||
case .picker: .default
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
FullscreenDialog(topPadding: 24) {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
.padding(.top, UIConstants.iconTopPaddingToNavigationBar)
|
||||
.padding(.bottom, 36)
|
||||
|
||||
serverForm
|
||||
}
|
||||
.readableFrame()
|
||||
.padding(.horizontal, 16)
|
||||
} bottomContent: {
|
||||
continueButton
|
||||
}
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
.toolbar { toolbar }
|
||||
.background()
|
||||
.backgroundStyle(backgroundColor)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle(context.viewState.screenTitle)
|
||||
.alert(item: $context.alertInfo)
|
||||
.interactiveDismissDisabled()
|
||||
.introspect(.window, on: .supportedVersions) { window in
|
||||
context.send(viewAction: .updateWindow(window))
|
||||
}
|
||||
}
|
||||
|
||||
/// The title, message and icon at the top of the screen.
|
||||
var header: some View {
|
||||
VStack(spacing: 8) {
|
||||
BigIcon(icon: \.host)
|
||||
BigIcon(icon: \.userProfileSolid, style: headerIconStyle)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Text(L10n.screenChangeServerTitle)
|
||||
Text(context.viewState.screenHeader)
|
||||
.font(.compound.headingMDBold)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
|
||||
Text(L10n.screenChangeServerSubtitle)
|
||||
.font(.compound.bodyMD)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
/// The text field and confirm button where the user enters a server URL.
|
||||
/// The main input and confirm button.
|
||||
var serverForm: some View {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
TextField(L10n.commonServerUrl, text: $context.homeserverAddress)
|
||||
.textFieldStyle(.compound(labelText: Text(L10n.screenChangeServerFormHeader),
|
||||
footerText: Text(context.viewState.footerMessage),
|
||||
state: context.viewState.isShowingFooterError ? .error : .default,
|
||||
accessibilityIdentifier: A11yIdentifiers.changeServerScreen.server))
|
||||
.keyboardType(.URL)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.onChange(of: context.homeserverAddress) { context.send(viewAction: .clearFooterError) }
|
||||
.submitLabel(.done)
|
||||
.onSubmit(submit)
|
||||
|
||||
Button(action: submit) {
|
||||
Text(L10n.actionContinue)
|
||||
switch context.viewState.mode {
|
||||
case .userInput:
|
||||
TextField(L10n.screenChangeServerTextfieldPlaceholder, text: $context.homeserverAddress, selection: $context.homeserverSelection)
|
||||
.textFieldStyle(.compound(labelText: Text(L10n.screenChangeServerTextfieldHeader),
|
||||
footerText: Text(context.viewState.footerMessage),
|
||||
state: context.viewState.isShowingFooterError ? .error : .default,
|
||||
accessibilityIdentifier: A11yIdentifiers.changeServerScreen.server))
|
||||
.keyboardType(.URL)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.introspect(.textField, on: .supportedVersions) {
|
||||
context.send(viewAction: .updateTextField($0))
|
||||
}
|
||||
.onChange(of: context.homeserverAddress) { context.send(viewAction: .clearFooterError) }
|
||||
.submitLabel(.done)
|
||||
.onSubmit(submit)
|
||||
case .picker(let providers):
|
||||
FakeInlinePicker(items: providers,
|
||||
icon: \.host,
|
||||
selection: $context.homeserverAddress)
|
||||
.accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.serverPicker)
|
||||
}
|
||||
.buttonStyle(.compound(.primary))
|
||||
.disabled(context.viewState.hasValidationError)
|
||||
.accessibilityIdentifier(A11yIdentifiers.changeServerScreen.continue)
|
||||
}
|
||||
}
|
||||
|
||||
var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button { context.send(viewAction: .dismiss) } label: {
|
||||
Text(L10n.actionCancel)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.changeServerScreen.dismiss)
|
||||
private var continueButton: some View {
|
||||
Button(action: submit) {
|
||||
Text(L10n.actionContinue)
|
||||
}
|
||||
.buttonStyle(.compound(.primary))
|
||||
.disabled(context.viewState.hasValidationError)
|
||||
.accessibilityIdentifier(A11yIdentifiers.changeServerScreen.continue)
|
||||
}
|
||||
|
||||
/// Sends the `confirm` view action so long as the text field input is valid.
|
||||
@@ -89,38 +106,81 @@ struct ServerSelectionScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private struct FakeInlinePicker: View {
|
||||
let items: [String]
|
||||
let icon: KeyPath<CompoundIcons, Image>
|
||||
@Binding var selection: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(items, id: \.self) { item in
|
||||
ListRow(label: .default(title: item, icon: icon),
|
||||
kind: .selection(isSelected: selection == item) {
|
||||
selection = item
|
||||
})
|
||||
.accessibilityLabel(item)
|
||||
.accessibilityIdentifier(item)
|
||||
.overlay(alignment: .bottom) {
|
||||
if item != items.last {
|
||||
Divider()
|
||||
.hidden()
|
||||
.overlay(Color.compound._borderInteractiveSecondaryAlpha)
|
||||
.padding(.leading, 54)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
@available(iOS 26.0, *)
|
||||
struct ServerSelection_Previews: PreviewProvider, TestablePreview {
|
||||
static let matrixViewModel = makeViewModel(for: "https://matrix.org")
|
||||
static let emptyViewModel = makeViewModel(for: "")
|
||||
static let invalidViewModel = makeViewModel(for: "thisisbad")
|
||||
static let matrixViewModel = makeViewModel(mode: .userInput, homeserverAddress: "matrix.org")
|
||||
static let emptyViewModel = makeViewModel(mode: .userInput, homeserverAddress: "")
|
||||
static let invalidViewModel = makeViewModel(mode: .userInput, homeserverAddress: "thisisbad")
|
||||
static let pickerViewModel = makeViewModel(mode: .picker(["matrix.org", "foo.bar", "baz.me"]), homeserverAddress: "foo.bar")
|
||||
|
||||
static var previews: some View {
|
||||
ElementNavigationStack {
|
||||
ServerSelectionScreen(context: matrixViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Matrix.org")
|
||||
|
||||
ElementNavigationStack {
|
||||
ServerSelectionScreen(context: emptyViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Empty")
|
||||
|
||||
ElementNavigationStack {
|
||||
ServerSelectionScreen(context: invalidViewModel.context)
|
||||
}
|
||||
.snapshotPreferences(expect: invalidViewModel.context.observe(\.viewState.hasValidationError))
|
||||
.previewDisplayName("Error")
|
||||
|
||||
ElementNavigationStack {
|
||||
ServerSelectionScreen(context: pickerViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Picker")
|
||||
}
|
||||
|
||||
static func makeViewModel(for homeserverAddress: String) -> ServerSelectionScreenViewModel {
|
||||
static func makeViewModel(mode: ServerSelectionScreenMode = .userInput, homeserverAddress: String) -> ServerSelectionScreenViewModel {
|
||||
let authenticationService = AuthenticationService.mock
|
||||
|
||||
let appSettings = AppSettings.volatile()
|
||||
|
||||
let viewModel = ServerSelectionScreenViewModel(authenticationService: authenticationService,
|
||||
mode: mode,
|
||||
authenticationFlow: .login,
|
||||
appSettings: .volatile(),
|
||||
appSettings: appSettings,
|
||||
homeserverHistoryManager: HomeserverHistoryManager(appSettings: appSettings),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
viewModel.context.homeserverAddress = homeserverAddress
|
||||
if homeserverAddress == "thisisbad" {
|
||||
if case .userInput = mode, homeserverAddress == "thisisbad" {
|
||||
viewModel.context.send(viewAction: .confirm)
|
||||
}
|
||||
return viewModel
|
||||
|
||||
Reference in New Issue
Block a user