diff --git a/Yattee/Views/Settings/InstanceAccountsSection.swift b/Yattee/Views/Settings/InstanceAccountsSection.swift index 2ba6fcf3..a62692d3 100644 --- a/Yattee/Views/Settings/InstanceAccountsSection.swift +++ b/Yattee/Views/Settings/InstanceAccountsSection.swift @@ -77,7 +77,7 @@ struct InstanceAccountsSection: View { } header: { Text(String(localized: "login.header.account")) } footer: { - Text(String(localized: accounts.isEmpty ? "login.footer.loginBenefits" : "login.footer.loggedIn")) + Text(footerText) } .onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in credentialRevision &+= 1 @@ -216,6 +216,45 @@ struct InstanceAccountsSection: View { // MARK: - Presentation + /// What the section says under the rows. + /// + /// Read from the statuses, not from the count: this is the screen the user + /// comes to in order to fix a login the server turned down, and a footer + /// that only counts accounts tells them, right there, that they are logged + /// in. The worst state on the list is the one that gets named — a rejected + /// credential is something to act on, and it outranks a server that + /// happens to be down. + private var footerText: String { + guard !accounts.isEmpty else { + return String(localized: "login.footer.loginBenefits") + } + + // `checking` counts as healthy: it is the absence of an answer, and a + // footer that reacted to it would blink a warning at every launch. + let unhealthy = accounts.map { status(for: $0) } + .filter { $0 != .signedIn && $0 != .checking } + + guard !unhealthy.isEmpty else { + return String(localized: "login.footer.loggedIn") + } + + if unhealthy.contains(.credentialRejected) { + return String(localized: "login.footer.credentialRejected") + } + + if unhealthy.contains(.unreachable) { + return String(localized: "login.footer.unreachable") + } + + if unhealthy.contains(.credentialMissing) { + return String(localized: "login.footer.credentialMissing") + } + + // A state named after this was written. Saying only that something is + // wrong is worth little, but it is true, and the rows above say what. + return String(localized: "login.footer.accountUnusable") + } + private func displayName(for account: Account) -> String { appEnvironment?.dataContextValidator.displayName(for: account) ?? instance.displayName } diff --git a/Yattee/Views/Settings/InstanceLoginView.swift b/Yattee/Views/Settings/InstanceLoginView.swift index a6c702b4..02b6fa3d 100644 --- a/Yattee/Views/Settings/InstanceLoginView.swift +++ b/Yattee/Views/Settings/InstanceLoginView.swift @@ -73,7 +73,12 @@ struct InstanceLoginView: View { .accessibilityIdentifier("instance.login.view") } #if os(macOS) - .frame(minWidth: 450, minHeight: 350) + // Sized to what the form actually holds: two fields and a button leave + // most of a taller sheet empty, and the optional step carries two more + // rows than the plain one. The width is what the labels have to fit + // in, which matters most where this is pushed on top of the add flow's + // own sheet. + .frame(minWidth: 480, minHeight: isOptionalStep ? 400 : 300) #endif #endif } @@ -84,6 +89,19 @@ struct InstanceLoginView: View { #if os(tvOS) TVSettingsTextField(title: usernameFieldLabel, text: $username) TVSettingsTextField(title: String(localized: "login.password"), text: $password, isSecure: true) + #elseif os(macOS) + LabeledContent(usernameFieldLabel) { + TextField("", text: $username) + .textContentType(.username) + .autocorrectionDisabled() + .accessibilityIdentifier("instance.login.usernameField") + } + + LabeledContent(String(localized: "login.password")) { + SecureField("", text: $password) + .textContentType(.password) + .accessibilityIdentifier("instance.login.passwordField") + } #else TextField(usernameFieldLabel, text: $username) .textContentType(.username) @@ -100,7 +118,7 @@ struct InstanceLoginView: View { } header: { Text(String(localized: "login.header.credentials")) } footer: { - Text(footerText) + wrappingFooter(footerText) } if let error = errorMessage { @@ -144,13 +162,17 @@ struct InstanceLoginView: View { .buttonStyle(TVSettingsButtonStyle()) #endif } footer: { - Text(String(localized: "login.footer.optionalStep")) + wrappingFooter(String(localized: "login.footer.optionalStep")) } } } #if os(iOS) .scrollDismissesKeyboard(.interactively) #endif + #if os(macOS) + .formStyle(.grouped) + .opaqueSettingsFormBackground() + #endif .onAppear { // Only ever fills an untouched field: the sheet's body runs again // for every keystroke, and a prefill that reasserted itself would @@ -163,6 +185,17 @@ struct InstanceLoginView: View { // MARK: - Computed Properties + /// A section footer that takes as many lines as it needs. + /// + /// The sentences here explain what the form is for and that skipping it is + /// safe, and the sheet is narrow — narrower still when it is pushed on top + /// of the add flow's sheet. Left to lay itself out, macOS keeps the footer + /// on one line and cuts the end of it off. + private func wrappingFooter(_ text: String) -> some View { + Text(text) + .fixedSize(horizontal: false, vertical: true) + } + /// What the first field is called on this backend. /// /// Named per backend rather than genericised: every backend that has