// // 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 { $0.endAt == nil }, sort: \TimeSession.startAt) private var runningSessions: [TimeSession] @AppStorage(SettingsKeys.gridColumns) private var gridColumns = 3 @AppStorage(SettingsKeys.minSessionSeconds) private var minSessionSeconds = 0 @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? private var anyRunning: Bool { !runningSessions.isEmpty } var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 16) { 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) } .confirmationDialog( "‘\(deletingAction?.name ?? "")’ 행동을 삭제할까요? 기록도 함께 삭제됩니다.", isPresented: Binding( get: { deletingAction != nil }, set: { if !$0 { deletingAction = nil } } ), titleVisibility: .visible ) { Button("삭제", role: .destructive) { if let action = deletingAction { context.delete(action) LiveActivityManager.sync(context: context) } deletingAction = nil } Button("취소", role: .cancel) { deletingAction = nil } } } } // 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 { 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: context.insert(CountEntry(action: action, timestamp: .now)) } } /// 세션 종료. "짧은 기록 무시" 설정보다 짧으면 기록하지 않고 삭제 (실수 방지) private func finish(_ session: TimeSession) { if minSessionSeconds > 0, session.duration() < Double(minSessionSeconds) { context.delete(session) } else { session.endAt = .now } 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 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() if action.trackingType == .count { 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) 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: .light), 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: Text(todayLabel) .font(.caption2) .foregroundStyle(.white.opacity(0.85)) .lineLimit(1) } } private var todayLabel: String { let agg = Aggregator() switch action.trackingType { case .time: let seconds = agg.seconds(for: action, in: agg.math.dayRange(containing: .now)) return seconds > 0 ? "오늘 \(Format.durationShort(seconds))" : "오늘 0분" case .count: let count = agg.count(for: action, in: agg.math.dayRange(containing: .now)) return "오늘 \(count)회" } } } struct PressableButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.93 : 1) .animation(.spring(duration: 0.2), value: configuration.isPressed) } }