444 lines
15 KiB
Swift
444 lines
15 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]
|
||
|
||
@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)
|
||
}
|
||
}
|