- Remove the maximum limit for selected goal widgets on the main tab - Unify the display format (aggregate vs. individual) to apply globally across all selected goals - Redesign the horizontal scrolling into a full-width snap carousel (pagination style) - Ensure only one widget is fully visible at a time with no adjacent cards partially showing - Apply snap scrolling so light swipes firmly transition to the next or previous widget
836 lines
32 KiB
Swift
836 lines
32 KiB
Swift
//
|
||
// 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: \Goal.createdAt) private var pinnedGoals: [Goal]
|
||
|
||
@Environment(AppRouter.self) private var router
|
||
|
||
@AppStorage(SettingsKeys.gridColumns) private var gridColumns = 3
|
||
@AppStorage(SettingsKeys.minSessionSeconds) 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 {
|
||
let quests = goal.sortedQuests
|
||
guard !quests.isEmpty else { return 0 }
|
||
let sum = quests.reduce(0.0) {
|
||
$0 + QuestProgress(quest: $1).spanProgress(span).ratio
|
||
}
|
||
return sum / Double(quests.count)
|
||
}
|
||
|
||
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
|
||
)
|
||
}
|
||
}
|