From 18e7bac7e41470fe099beb3cf55fdf17c97987cf Mon Sep 17 00:00:00 2001 From: systemblue Date: Fri, 5 Jun 2026 22:05:48 -0400 Subject: [PATCH] feat: size the sign-in button as a large control with a stable loading state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Forji/Forji/Views/InstanceFormView.swift | 35 ++++++++++++++++++------ 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/Forji/Forji/Views/InstanceFormView.swift b/Forji/Forji/Views/InstanceFormView.swift index 8a72c9e..087c7bf 100644 --- a/Forji/Forji/Views/InstanceFormView.swift +++ b/Forji/Forji/Views/InstanceFormView.swift @@ -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