diff --git a/Forji/Forji/Views/InstanceFormView.swift b/Forji/Forji/Views/InstanceFormView.swift index 5d99253..716344b 100644 --- a/Forji/Forji/Views/InstanceFormView.swift +++ b/Forji/Forji/Views/InstanceFormView.swift @@ -143,19 +143,11 @@ struct InstanceFormView: View { } Section { - Button(action: handleSave) { - ZStack { - Text(isAddMode ? "Login" : "Save") - .fontWeight(.semibold) - .opacity(isLoading ? 0 : 1) - if isLoading { - ProgressView() - .progressViewStyle(.circular) - .tint(.white) - } - } - } - .buttonStyle(FlatPrimaryButtonStyle()) + LoadingActionButton( + title: isAddMode ? "Login" : "Save", + isLoading: isLoading, + action: handleSave, + ) .disabled( isLoading || serverURL.isEmpty || (authMode == .credentials @@ -329,38 +321,6 @@ struct InstanceFormView: View { } } -/// A full-width primary button style with a large hit target. -/// -/// Defines its own appearance instead of relying on `.borderedProminent`, whose built-in -/// press treatment springs/scales on release — on iOS 26 that read as the button's lower -/// edge "collapsing" when the finger lifted. This style changes only the background opacity -/// on press, with no `scaleEffect` and no spring, so the frame never moves. -private struct FlatPrimaryButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - Background(configuration: configuration) - } - - private struct Background: View { - let configuration: ButtonStyleConfiguration - @Environment(\.isEnabled) private var isEnabled - - var body: some View { - configuration.label - .font(.headline) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(Color.accentColor.opacity(backgroundOpacity), in: Capsule()) - .contentShape(Capsule()) - } - - private var backgroundOpacity: Double { - if !isEnabled { return 0.35 } - return configuration.isPressed ? 0.85 : 1.0 - } - } -} - #if DEBUG #Preview("Add") { InstanceFormView(authService: .previewDefault, mode: .add) diff --git a/Forji/Forji/Views/LoadingActionButton.swift b/Forji/Forji/Views/LoadingActionButton.swift new file mode 100644 index 0000000..ea5d116 --- /dev/null +++ b/Forji/Forji/Views/LoadingActionButton.swift @@ -0,0 +1,78 @@ +import SwiftUI + +/// A full-width primary action button that swaps its label for a spinner while a +/// task is in flight, keeping its frame steady so the layout never jumps. +/// +/// The loading-state `ZStack` and the press treatment live here so callers just +/// supply a title, a loading flag, and an action. +struct LoadingActionButton: View { + let title: String + let isLoading: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + ZStack { + Text(title) + .fontWeight(.semibold) + .opacity(isLoading ? 0 : 1) + if isLoading { + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + } + } + } + .buttonStyle(FlatPrimaryButtonStyle()) + } +} + +/// A full-width primary button style with a large hit target and a fully rounded shape. +/// +/// Defines its own appearance instead of relying on `.borderedProminent`, whose built-in +/// press treatment springs/scales on release — on iOS 26 that read as the button's lower +/// edge "collapsing" when the finger lifted. This style changes only the background opacity +/// on press, with no `scaleEffect` and no spring, so the frame never moves. +private struct FlatPrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + Background(configuration: configuration) + } + + private struct Background: View { + let configuration: ButtonStyleConfiguration + @Environment(\.isEnabled) private var isEnabled + + var body: some View { + configuration.label + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.accentColor.opacity(backgroundOpacity), in: Capsule()) + .contentShape(Capsule()) + } + + private var backgroundOpacity: Double { + if !isEnabled { return 0.35 } + return configuration.isPressed ? 0.85 : 1.0 + } + } +} + +#if DEBUG + #Preview("Idle") { + LoadingActionButton(title: "Login", isLoading: false) {} + .padding() + } + + #Preview("Loading") { + LoadingActionButton(title: "Login", isLoading: true) {} + .padding() + } + + #Preview("Disabled") { + LoadingActionButton(title: "Login", isLoading: false) {} + .disabled(true) + .padding() + } +#endif diff --git a/Forji/ForjiTests/LoadingActionButtonTests.swift b/Forji/ForjiTests/LoadingActionButtonTests.swift new file mode 100644 index 0000000..a56a260 --- /dev/null +++ b/Forji/ForjiTests/LoadingActionButtonTests.swift @@ -0,0 +1,19 @@ +@testable import Forji +import Foundation +import Testing + +@MainActor +struct LoadingActionButtonTests { + @Test func actionClosureIsInvoked() { + var tapped = false + let button = LoadingActionButton(title: "Login", isLoading: false) { tapped = true } + button.action() + #expect(tapped) + } + + @Test func exposesTitleAndLoadingState() { + let button = LoadingActionButton(title: "Save", isLoading: true) {} + #expect(button.title == "Save") + #expect(button.isLoading) + } +}