feat: size the sign-in button as a large control with a stable loading state

The form's primary action rendered at the default control size, which
reads small for the screen's one main action. Use .controlSize(.large)
so the Login/Save button gets the standard large-CTA height.

The loading state emptied the button: the title faded out and a lone
spinner floated on a wide prominent capsule, which read as a hollow
button rather than work in progress. Keep the button full instead, with
a leading spinner and a status label that switches the title to its
present-progressive form (Signing in… / Saving…) while the request runs.
The spinner and label cross-fade with a blur-replace morph that Reduce
Motion drops, the button keeps its prominent appearance by guarding
re-entry in handleSave rather than disabling, and the title stays present
so the button never resizes mid-action.

The file crosses SwiftLint's default 400-line warning; disabled at the
top like Seeder.swift.
This commit is contained in:
systemblue 2026-06-05 22:05:48 -04:00
parent 2a679140e6
commit 18e7bac7e4

View file

@ -2,6 +2,7 @@ import ForgejoKit
import SwiftData
import SwiftUI
// swiftlint:disable file_length
// swiftlint:disable:next type_body_length
struct InstanceFormView: View {
enum Mode: Identifiable {
@ -23,6 +24,7 @@ struct InstanceFormView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Query(sort: \ForgejoInstance.lastUsed, order: .reverse) private var instances: [ForgejoInstance]
@State private var authService: AuthenticationService
@ -55,6 +57,14 @@ struct InstanceFormView: View {
self.mode = mode
}
/// "Login"/"Save" at rest; the present-progressive form while the request runs.
private var buttonTitle: String {
if isLoading {
return isAddMode ? "Signing in…" : "Saving…"
}
return isAddMode ? "Login" : "Save"
}
var body: some View {
NavigationStack {
Form {
@ -144,21 +154,27 @@ struct InstanceFormView: View {
Section {
Button(action: handleSave) {
HStack {
Spacer()
// A leading spinner with a status label keeps the button
// full while signing in, instead of emptying it to a lone
// spinner. Reduce Motion drops the morph.
HStack(spacing: 8) {
if isLoading {
ProgressView()
.progressViewStyle(.circular)
} else {
Text(isAddMode ? "Login" : "Save")
.fontWeight(.semibold)
.controlSize(.regular)
.tint(.white)
.transition(.blurReplace)
}
Spacer()
Text(buttonTitle)
.fontWeight(.semibold)
.contentTransition(.opacity)
}
.frame(maxWidth: .infinity)
.animation(reduceMotion ? nil : .smooth(duration: 0.3), value: isLoading)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(
isLoading || serverURL.isEmpty
serverURL.isEmpty
|| (authMode == .credentials
&& (username.isEmpty || (isAddMode && password.isEmpty)
|| (needsOTP && otpCode.isEmpty)))
@ -242,6 +258,9 @@ struct InstanceFormView: View {
// swiftlint:disable:next function_body_length cyclomatic_complexity
private func handleSave() {
// The button keeps its prominent look while loading; guard re-entry here.
guard !isLoading else { return }
if !name.isEmpty {
let conflict = instances.first { inst in
let instId = inst.serverURL + inst.username