mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
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:
parent
a1dea935b0
commit
20ab61eb43
3 changed files with 102 additions and 45 deletions
|
|
@ -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)
|
||||
|
|
|
|||
78
Forji/Forji/Views/LoadingActionButton.swift
Normal file
78
Forji/Forji/Views/LoadingActionButton.swift
Normal 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
|
||||
19
Forji/ForjiTests/LoadingActionButtonTests.swift
Normal file
19
Forji/ForjiTests/LoadingActionButtonTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue