mycode/myApp/HaruDanim/IOS/Views/MainView.swift
songyc macbook f468fdf296 feat(data): SwiftData 저장소를 App Group으로 이관하고 CloudKit 동기화 지원
- DataStore: App Group 컨테이너의 HaruDanim.store 사용, 기존 default.store 1회 자동 이관
- 프리미엄 + 동기화 토글 시 CloudKit(.private) 구성으로 컨테이너 생성, 실패하면 로컬 폴백
- CloudKit 요건(모든 관계 optional)에 맞춰 to-many 관계를 optional 저장 + 기존 이름
  접근자로 리팩터링 (originalName 매핑으로 기존 데이터 유지)
- Tag/Action/Goal/Quest에 uuid 추가 (시리 단축어·위젯·워치 식별자) + 중복 보정
- 모델 레이어(Models/Settings/DayMath/QuestProgress/Formatters/Theme)를 Shared/로 이동
  — 위젯 확장과 공유
- 집계 설정(주 시작 요일·하루 시작 시간·대표 시간·짧은 기록 무시)을 App Group defaults로
  이관해 위젯·워치와 공유, 설정의 '기기 간 동기화' 토글 활성화
- Goal.combinedSpanRatio 공용화 (모음 탭 카드·위젯·시리가 동일 산식 사용)
- DEBUG 검증 인자: -cloudSync YES, -settingsScrollPremium YES

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-10 08:59:15 +09:00

834 lines
32 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// MainView.swift
// Haru_Danim
//
// : + + + (CLAUDE.md §6.1)
//
import SwiftUI
import SwiftData
import UniformTypeIdentifiers
struct MainView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Action.sortOrder) private var actions: [Action]
@Query(filter: #Predicate<TimeSession> { $0.endAt == nil }, sort: \TimeSession.startAt)
private var runningSessions: [TimeSession]
@Query(
filter: #Predicate<Goal> { $0.showsOnMain },
sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]
) private var pinnedGoals: [Goal]
@Environment(AppRouter.self) private var router
@AppStorage(SettingsKeys.gridColumns) private var gridColumns = 3
@AppStorage(SettingsKeys.minSessionSeconds, store: AppGroup.defaults) private var minSessionSeconds = 0
@AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs
@State private var isEditing: Bool = {
#if DEBUG
return UserDefaults.standard.bool(forKey: "startEditing")
#else
return false
#endif
}()
@State private var draggingAction: Action?
@State private var settingsEditingAction: Action?
@State private var recordsAction: Action?
@State private var deletingAction: Action?
@State private var memoSession: TimeSession?
@State private var memoEntry: CountEntry?
@State private var pagedGoalID: PersistentIdentifier?
@State private var goalCardHeights: [PersistentIdentifier: CGFloat] = [:]
private var anyRunning: Bool { !runningSessions.isEmpty }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if !pinnedGoals.isEmpty && !isEditing {
goalArea
}
if anyRunning && !isEditing {
runningArea
}
if isEditing {
layoutControl
}
if actions.isEmpty {
emptyState
} else {
grid
}
}
.padding()
}
.background(AppTheme.background)
.navigationTitle("모음")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(isEditing ? "완료" : "배치 편집") {
withAnimation {
isEditing.toggle()
}
}
.fontWeight(isEditing ? .bold : .regular)
}
}
.sheet(item: $settingsEditingAction) { action in
ActionEditorView(action: action)
}
.sheet(item: $recordsAction) { action in
ActionRecordsSheet(action: action, startWithAdd: true)
}
.sheet(item: $memoSession) { session in
SessionMemoSheet(session: session)
}
.sheet(item: $memoEntry) { entry in
CountMemoSheet(entry: entry)
}
.alert(
"행동 삭제",
isPresented: Binding(
get: { deletingAction != nil },
set: { if !$0 { deletingAction = nil } }
),
presenting: deletingAction
) { action in
Button("삭제", role: .destructive) {
context.delete(action)
LiveActivityManager.sync(context: context)
deletingAction = nil
}
Button("취소", role: .cancel) { deletingAction = nil }
} message: { action in
Text("\(action.name) 행동을 삭제할까요? 기록도 함께 삭제됩니다.")
}
#if DEBUG
// : -openStatsFor "" / -openHistoryFor ""
.onAppear {
if let name = UserDefaults.standard.string(forKey: "openStatsFor"),
let action = actions.first(where: { $0.name == name }) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
router.openStats(filtering: action.persistentModelID, visibleTabsRaw: visibleTabsRaw)
}
}
if let name = UserDefaults.standard.string(forKey: "openHistoryFor"),
let action = actions.first(where: { $0.name == name }) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
router.openHistory(filtering: action.persistentModelID, visibleTabsRaw: visibleTabsRaw)
}
}
}
#endif
}
// MARK: (1= , 2 = )
@ViewBuilder
private var goalArea: some View {
if pinnedGoals.count == 1, let goal = pinnedGoals.first {
GoalSummaryCard(goal: goal)
} else {
VStack(spacing: 8) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 10) {
ForEach(pinnedGoals) { goal in
GoalSummaryCard(goal: goal)
// 1
.containerRelativeFrame(.horizontal)
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.height
} action: { height in
goalCardHeights[goal.persistentModelID] = height
}
}
}
.scrollTargetLayout()
}
// ( )
.scrollTargetBehavior(.viewAligned(limitBehavior: .always))
.scrollPosition(id: $pagedGoalID)
// ,
.frame(height: currentGoalCardHeight, alignment: .top)
.clipped()
.animation(.snappy(duration: 0.25), value: currentGoalCardHeight)
goalPageIndicator
}
}
}
private var currentPagedGoalID: PersistentIdentifier? {
pagedGoalID ?? pinnedGoals.first?.persistentModelID
}
private var currentGoalCardHeight: CGFloat? {
currentPagedGoalID.flatMap { goalCardHeights[$0] }
}
private var goalPageIndicator: some View {
HStack(spacing: 5) {
ForEach(pinnedGoals) { goal in
Circle()
.fill(
goal.persistentModelID == currentPagedGoalID
? AnyShapeStyle(AppTheme.green)
: AnyShapeStyle(.tertiary)
)
.frame(width: 5, height: 5)
}
}
.frame(maxWidth: .infinity)
.animation(.default, value: pagedGoalID)
}
// MARK:
private var runningArea: some View {
VStack(alignment: .leading, spacing: 8) {
Label("현재 진행 중", systemImage: "record.circle")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
ForEach(runningSessions) { session in
if let action = session.action {
RunningSessionRow(session: session, action: action) {
stop(session)
}
}
}
}
.padding(14)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
// MARK: ( )
private var layoutControl: some View {
VStack(alignment: .leading, spacing: 8) {
Label("한 줄에 표시할 개수", systemImage: "square.grid.3x3")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
Picker("한 줄에 표시할 개수", selection: $gridColumns) {
ForEach(2...6, id: \.self) { n in
Text("\(n)").tag(n)
}
}
.pickerStyle(.segmented)
Text("버튼을 길게 눌러 끌면 순서를 바꿀 수 있어요.")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(14)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
// MARK:
private var grid: some View {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: gridColumns),
spacing: 12
) {
ForEach(actions) { action in
cell(for: action)
}
}
.animation(.default, value: actions.map(\.persistentModelID))
.animation(.default, value: gridColumns)
}
@ViewBuilder
private func cell(for action: Action) -> some View {
let base = ActionButtonCell(action: action, isEditing: isEditing, compact: gridColumns >= 5) {
handleTap(action)
}
.modifier(JiggleEffect(active: isEditing))
if isEditing {
base
.onDrag {
draggingAction = action
return NSItemProvider(object: action.name as NSString)
}
.onDrop(
of: [UTType.text],
delegate: ActionReorderDelegate(
item: action,
dragging: $draggingAction,
move: moveDragging(to:)
)
)
} else {
base
.contextMenu {
Button {
//
router.openHistory(
filtering: action.persistentModelID,
visibleTabsRaw: visibleTabsRaw
)
} label: {
Label("기록 확인", systemImage: "list.bullet.rectangle")
}
Button {
//
router.openStats(
filtering: action.persistentModelID,
visibleTabsRaw: visibleTabsRaw
)
} label: {
Label("통계 보기", systemImage: "chart.xyaxis.line")
}
Button {
recordsAction = action
} label: {
Label(
action.trackingType == .time ? "시작·종료 시각 수동 입력" : "횟수 직접 입력·수정",
systemImage: "square.and.pencil"
)
}
Button {
settingsEditingAction = action
} label: {
Label("행동 설정 수정", systemImage: "slider.horizontal.3")
}
Button(role: .destructive) {
deletingAction = action
} label: {
Label("행동 삭제", systemImage: "trash")
}
}
}
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "square.grid.2x2")
.font(.system(size: 44))
.foregroundStyle(.tertiary)
Text("아직 등록된 행동이 없어요")
.font(.headline)
Text("행동 탭에서 추적할 행동을 추가해 보세요.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 60)
}
// MARK:
private func handleTap(_ action: Action) {
guard !isEditing else { return }
switch action.trackingType {
case .time:
if let session = action.runningSession {
finish(session)
} else {
context.insert(TimeSession(action: action, startAt: .now))
LiveActivityManager.sync(context: context)
}
case .count:
let entry = CountEntry(action: action, timestamp: .now)
context.insert(entry)
if action.promptsForNote {
memoEntry = entry
}
}
}
/// . " " ( )
/// ( ).
private func finish(_ session: TimeSession) {
if minSessionSeconds > 0, session.duration() < Double(minSessionSeconds) {
context.delete(session)
} else {
session.endAt = .now
if session.action?.promptsForNote == true {
memoSession = session
}
}
LiveActivityManager.sync(context: context)
}
private func stop(_ session: TimeSession) {
finish(session)
}
private func moveDragging(to target: Action) {
guard let dragging = draggingAction, dragging != target else { return }
var ordered = actions
guard let from = ordered.firstIndex(of: dragging),
let to = ordered.firstIndex(of: target) else { return }
ordered.remove(at: from)
ordered.insert(dragging, at: to)
for (index, action) in ordered.enumerated() {
action.sortOrder = index
}
}
}
// MARK: - ( , )
struct GoalSummaryCard: View {
let goal: Goal
@AppStorage(SettingsKeys.goalCardStyle) private var cardStyleRaw = GoalCardStyle.perQuest.rawValue
private var cardStyle: GoalCardStyle {
GoalCardStyle(rawValue: cardStyleRaw) ?? .perQuest
}
var body: some View {
NavigationLink {
GoalDetailView(goal: goal)
} label: {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Image(systemName: goal.symbolName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 32, height: 32)
.background(goal.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
VStack(alignment: .leading, spacing: 1) {
Text("목표 진행 현황")
.font(.caption2)
.foregroundStyle(.secondary)
Text(goal.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
if goal.status == .inProgress, let progress = goal.dateProgress() {
VStack(alignment: .leading, spacing: 3) {
ProgressView(value: progress)
.tint(goal.color)
Text("기간 진행률 \(Format.percent(progress))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if !goal.sortedQuests.isEmpty {
//
switch cardStyle {
case .perQuest:
ForEach(goal.sortedQuests.prefix(3)) { quest in
questSpanBlock(quest)
}
case .combined:
combinedBlock
}
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .topLeading)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
.buttonStyle(.plain)
}
/// A: //
private func questSpanBlock(_ quest: Quest) -> some View {
let progress = QuestProgress(quest: quest)
return VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: quest.targetSymbol)
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 18, height: 18)
.background(quest.targetColor, in: RoundedRectangle(cornerRadius: 5, style: .continuous))
Text(quest.targetName)
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
}
HStack(spacing: 10) {
ForEach(StatSpan.allCases) { span in
let result = progress.spanProgress(span)
let over = quest.direction == .atMost && result.value > result.target
miniGauge(
label: span.label,
ratio: result.ratio,
percentText: Format.percent(result.displayRatio),
color: over ? .red : quest.targetColor,
emphasized: over
)
}
}
}
}
/// B: //
private var combinedBlock: some View {
HStack(spacing: 10) {
ForEach(StatSpan.allCases) { span in
let ratio = combinedRatio(span)
miniGauge(
label: "\(span.label) 전체",
ratio: ratio,
percentText: Format.percent(ratio),
color: goal.color,
emphasized: false
)
}
}
}
/// span ( 100% 0% )
private func combinedRatio(_ span: StatSpan) -> Double {
goal.combinedSpanRatio(span)
}
private func miniGauge(
label: String,
ratio: Double,
percentText: String,
color: Color,
emphasized: Bool
) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
ProgressView(value: min(max(ratio, 0), 1))
.tint(color)
Text(percentText)
.font(.caption2.weight(.semibold).monospacedDigit())
.foregroundStyle(emphasized ? .red : .secondary)
}
.frame(maxWidth: .infinity)
}
}
// MARK: -
struct SessionMemoSheet: View {
@Environment(\.dismiss) private var dismiss
let session: TimeSession
@State private var text = ""
@FocusState private var focused: Bool
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 12) {
if let action = session.action {
HStack(spacing: 10) {
Image(systemName: action.symbolName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 32, height: 32)
.background(action.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
VStack(alignment: .leading, spacing: 1) {
Text(action.name)
.font(.subheadline.weight(.semibold))
Text("\(Format.durationShort(session.duration())) 측정 완료")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
TextField("이번 측정에 대한 메모 (선택)", text: $text, axis: .vertical)
.lineLimit(3...5)
.padding(10)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.focused($focused)
Spacer()
}
.padding()
.background(AppTheme.background)
.navigationTitle("메모 남기기")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("건너뛰기") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") {
session.note = text.trimmingCharacters(in: .whitespacesAndNewlines)
dismiss()
}
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
.presentationDetents([.height(280)])
.onAppear { text = session.note }
}
}
// MARK: -
struct CountMemoSheet: View {
@Environment(\.dismiss) private var dismiss
let entry: CountEntry
@State private var text = ""
@FocusState private var focused: Bool
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 12) {
if let action = entry.action {
HStack(spacing: 10) {
Image(systemName: action.symbolName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 32, height: 32)
.background(action.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
VStack(alignment: .leading, spacing: 1) {
Text(action.name)
.font(.subheadline.weight(.semibold))
Text("+\(entry.amount) 기록 완료")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
TextField("이번 기록에 대한 메모 (선택)", text: $text, axis: .vertical)
.lineLimit(3...5)
.padding(10)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.focused($focused)
Spacer()
}
.padding()
.background(AppTheme.background)
.navigationTitle("메모 남기기")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("건너뛰기") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") {
entry.note = text.trimmingCharacters(in: .whitespacesAndNewlines)
dismiss()
}
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
.presentationDetents([.height(280)])
.onAppear { text = entry.note }
}
}
// MARK: - ()
struct JiggleEffect: ViewModifier {
let active: Bool
@State private var angle: Double = 0
func body(content: Content) -> some View {
content
.rotationEffect(.degrees(angle))
.onChange(of: active) { _, on in
if on {
angle = -1.7
withAnimation(.easeInOut(duration: 0.13).repeatForever(autoreverses: true)) {
angle = 1.7
}
} else {
withAnimation(.easeOut(duration: 0.15)) {
angle = 0
}
}
}
.onAppear {
if active {
angle = -1.7
withAnimation(.easeInOut(duration: 0.13).repeatForever(autoreverses: true)) {
angle = 1.7
}
}
}
}
}
// MARK: -
private struct ActionReorderDelegate: DropDelegate {
let item: Action
@Binding var dragging: Action?
let move: (Action) -> Void
func dropEntered(info: DropInfo) {
move(item)
}
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
dragging = nil
return true
}
}
// MARK: - ( )
struct RunningSessionRow: View {
let session: TimeSession
let action: Action
let onStop: () -> Void
var body: some View {
HStack(spacing: 10) {
Image(systemName: action.symbolName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
.background(action.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
VStack(alignment: .leading, spacing: 1) {
Text(action.name)
.font(.subheadline.weight(.semibold))
Text("\(Format.time(session.startAt)) 시작")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
TimelineView(.periodic(from: .now, by: 1)) { timeline in
Text(Format.timer(timeline.date.timeIntervalSince(session.startAt)))
.font(.body.weight(.semibold).monospacedDigit())
.foregroundStyle(AppTheme.green)
}
Button(action: onStop) {
Image(systemName: "stop.circle.fill")
.font(.system(size: 28))
.foregroundStyle(AppTheme.yellow)
}
.buttonStyle(.plain)
}
}
}
// MARK: -
struct ActionButtonCell: View {
let action: Action
let isEditing: Bool
var compact: Bool = false
let onTap: () -> Void
@State private var bounce = false
private var cornerRadius: CGFloat { compact ? 16 : 22 }
var body: some View {
Button {
onTap()
bounce.toggle()
} label: {
VStack(alignment: .leading, spacing: compact ? 3 : 6) {
HStack {
Image(systemName: action.symbolName)
.font(.system(size: compact ? 17 : 26, weight: .medium))
.foregroundStyle(.white)
.symbolEffect(.bounce, value: bounce)
Spacer()
if action.isRunning && !compact {
Image(systemName: "record.circle")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(AppTheme.yellow)
.symbolEffect(.pulse, options: .repeating)
}
}
Spacer(minLength: 0)
Text(action.name)
.font(compact ? .caption2.weight(.semibold) : .footnote.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(compact ? 1 : 2)
.multilineTextAlignment(.leading)
footer
}
.padding(compact ? 8 : 12)
.frame(minHeight: compact ? 68 : 104, alignment: .topLeading)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(action.color.gradient)
)
.overlay {
if action.isRunning {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.strokeBorder(AppTheme.yellow, lineWidth: compact ? 2 : 3)
}
}
}
.buttonStyle(PressableButtonStyle())
.sensoryFeedback(.impact(weight: .medium), trigger: bounce)
.disabled(isEditing)
}
@ViewBuilder
private var footer: some View {
switch action.trackingType {
case .time:
if let session = action.runningSession {
TimelineView(.periodic(from: .now, by: 1)) { timeline in
Text(Format.timer(timeline.date.timeIntervalSince(session.startAt)))
.font((compact ? Font.caption2 : Font.caption).weight(.bold).monospacedDigit())
.foregroundStyle(AppTheme.yellow)
}
} else {
Text(todayLabel)
.font(.caption2)
.foregroundStyle(.white.opacity(0.85))
.lineLimit(1)
}
case .count:
HStack(alignment: .firstTextBaseline, spacing: compact ? 2 : 3) {
if !compact {
Text("오늘")
.font(.caption2)
.foregroundStyle(.white.opacity(0.75))
}
Text("\(todayCount)")
.font(.system(size: compact ? 18 : 27, weight: .heavy, design: .rounded))
.monospacedDigit()
.foregroundStyle(.white)
.contentTransition(.numericText(value: Double(todayCount)))
Text("")
.font((compact ? Font.caption2 : Font.caption).weight(.semibold))
.foregroundStyle(.white.opacity(0.85))
}
.lineLimit(1)
.animation(.snappy(duration: 0.3), value: todayCount)
}
}
private var todayLabel: String {
let agg = Aggregator()
let seconds = agg.seconds(for: action, in: agg.math.dayRange(containing: .now))
return seconds > 0 ? "오늘 \(Format.durationShort(seconds))" : "오늘 0분"
}
private var todayCount: Int {
let agg = Aggregator()
return agg.count(for: action, in: agg.math.dayRange(containing: .now))
}
}
/// ( )
struct PressableButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.85 : 1)
.brightness(configuration.isPressed ? 0.08 : 0)
.animation(
configuration.isPressed
? .spring(duration: 0.12)
: .spring(response: 0.35, dampingFraction: 0.45),
value: configuration.isPressed
)
}
}