// // 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.createdAt) private var actions: [Action] @Query(filter: #Predicate { $0.endAt == nil }, sort: \TimeSession.startAt) private var runningSessions: [TimeSession] @Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var allGoals: [Goal] // 배치 순서·노출 목표는 기기별 로컬 설정 (CloudKit 동기화 제외, LocalPrefs 참고) @AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = "" @AppStorage(LocalPrefsKeys.pinnedGoals, store: AppGroup.defaults) private var pinnedGoalsRaw = "" private var orderedActions: [Action] { LocalPrefs.orderedActions(actions, raw: actionOrderRaw) } private var pinnedGoals: [Goal] { allGoals.filter { LocalPrefs.contains($0.uuid, in: pinnedGoalsRaw) } } @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: .topBarLeading) { // 다른 기기에서 바뀐 데이터를 앱 재시작 없이 다시 불러오는 새로고침 RefreshButton() } ToolbarItem(placement: .topBarTrailing) { Button(isEditing ? String(localized: "완료") : String(localized: "배치 편집")) { withAnimation { isEditing.toggle() } // 편집 완료 시 바뀐 배치 순서를 위젯 기본 표시 순서에도 반영 if !isEditing { DataChange.commit(context: context) } } .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) DataChange.commit(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 DeviceLayout.isPad { // 넓은 화면: 페이징 대신 카드를 여러 장 나란히 배치 (폭도 카드 단위로 제한) LazyVGrid( columns: [GridItem(.adaptive(minimum: 340, maximum: 520), spacing: 10, alignment: .top)], alignment: .leading, spacing: 10 ) { ForEach(pinnedGoals) { goal in GoalSummaryCard(goal: goal) } } } else 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) { // iPad·Mac은 화면 폭에 맞춰 열 개수가 자동이므로 한 줄 개수 설정을 숨긴다 if !DeviceLayout.isPad { 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) .frame(maxWidth: .infinity, alignment: .leading) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } // MARK: 그리드 /// iPhone: 설정한 한 줄 개수 고정 / iPad·Mac: 버튼 크기 기준으로 화면 폭에 맞춰 자동 채움 private var gridLayout: [GridItem] { if DeviceLayout.isPad { return [GridItem(.adaptive(minimum: 150, maximum: 220), spacing: 12)] } return Array(repeating: GridItem(.flexible(), spacing: 12), count: gridColumns) } private var grid: some View { LazyVGrid(columns: gridLayout, spacing: 12) { ForEach(orderedActions) { action in cell(for: action) } } .animation(.default, value: orderedActions.map(\.persistentModelID)) .animation(.default, value: gridColumns) } @ViewBuilder private func cell(for action: Action) -> some View { let base = ActionButtonCell( action: action, isEditing: isEditing, compact: DeviceLayout.isPad ? false : 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 ? String(localized: "시작·종료 시각 수동 입력") : String(localized: "횟수 직접 입력·수정"), 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)) DataChange.commit(context: context) } case .count: let entry = CountEntry(action: action, timestamp: .now) context.insert(entry) DataChange.commit(context: context) if LocalPrefs.promptsForNote(action.uuid) { memoEntry = entry } } } /// 세션 종료. "짧은 기록 무시" 설정보다 짧으면 기록하지 않고 삭제 (실수 방지) /// 정상 기록된 세션은 행동의 메모 옵션이 켜져 있으면 메모 작성 시트를 띄운다 (건너뛰기 가능). private func finish(_ session: TimeSession) { if minSessionSeconds > 0, session.duration() < Double(minSessionSeconds) { context.delete(session) } else { session.endAt = .now if let action = session.action, LocalPrefs.promptsForNote(action.uuid) { memoSession = session } } DataChange.commit(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 = orderedActions guard let from = ordered.firstIndex(of: dragging), let to = ordered.firstIndex(of: target) else { return } ordered.remove(at: from) ordered.insert(dragging, at: to) // 배치는 기기별 로컬 설정 — CloudKit으로 동기화되는 모델(sortOrder)에는 쓰지 않는다 actionOrderRaw = LocalPrefs.rawValue(ordered.map(\.uuid)) } } // MARK: - 목표 진행 현황 카드 (설정에서 선택한 목표, 탭하면 목표 상세로 이동) struct GoalSummaryCard: View { let goal: Goal @AppStorage(SettingsKeys.goalCardStyle) private var cardStyleRaw = GoalCardStyle.perQuest.rawValue /// 다짐이 많아도 카드가 화면을 다 덮지 않게, 접힌 상태에서는 요약 개수만 보여주고 펼쳐서 전체를 본다 @State private var showsAllQuests: Bool = { #if DEBUG // 검증용: -expandGoalCard YES → 펼친 상태로 시작 return UserDefaults.standard.bool(forKey: "expandGoalCard") #else return false #endif }() private var cardStyle: GoalCardStyle { GoalCardStyle(rawValue: cardStyleRaw) ?? .perQuest } /// 접힌 상태에서 보여줄 다짐 수 (iPad는 카드가 그리드에 나란히 놓여 세로 여유가 있음) private var collapsedQuestLimit: Int { DeviceLayout.isPad ? 6 : 3 } 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: perQuestList 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 목록: 접힌 상태에서는 요약 개수까지만, 펼치면 소속 다짐 전체를 표시 @ViewBuilder private var perQuestList: some View { let quests = goal.sortedQuests let visible = showsAllQuests ? quests : Array(quests.prefix(collapsedQuestLimit)) ForEach(visible) { quest in questSpanBlock(quest) } if quests.count > collapsedQuestLimit { questExpandToggle(hiddenCount: quests.count - collapsedQuestLimit) } } /// 카드가 NavigationLink 안에 있어도 이 버튼 영역의 탭은 펼침/접힘으로만 동작한다 private func questExpandToggle(hiddenCount: Int) -> some View { Button { withAnimation(.snappy(duration: 0.25)) { showsAllQuests.toggle() } } label: { HStack(spacing: 4) { Text(showsAllQuests ? String(localized: "접기") : String(localized: "다짐 \(hiddenCount)개 더 보기")) Image(systemName: showsAllQuests ? "chevron.up" : "chevron.down") .font(.caption2.weight(.semibold)) } .font(.caption.weight(.medium)) .foregroundStyle(AppTheme.green) .frame(maxWidth: .infinity) .padding(.vertical, 5) .background(AppTheme.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) .contentShape(RoundedRectangle(cornerRadius: 9, 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) // 영어 등 긴 단위 문구("times")도 셀 안에 들어가도록 축소 허용 .minimumScaleFactor(0.6) .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 ? String(localized: "오늘 \(Format.durationShort(seconds))") : String(localized: "오늘 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 ) } }