refactor: extract LoadingActionButton component

Pull the login button's loading-state ZStack and the capsule press style out
of InstanceFormView into a reusable LoadingActionButton(title:isLoading:action:).
The view now just calls it; the spinner/label frame-stability logic lives in one
place and is unit-tested directly.

Matches the repo's house pattern (plain @State views + @Observable services, no
ObservableObject view models) — no MVVM introduced.

Generated with Claude Opus 4.8 (1M)
This commit is contained in:
Claude 2026-05-29 14:57:49 -04:00
parent a1dea935b0
commit 20ab61eb43
3 changed files with 102 additions and 45 deletions

View file

@ -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)

View file

@ -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

View file

@ -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)
}
}