// // 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 = "" /// '나머지 행동' 섹션 접힘(기기별) — 즐겨찾기가 있을 때만 섹션이 생기므로 그때만 의미 있음 @AppStorage(LocalPrefsKeys.mainOthersCollapsed, store: AppGroup.defaults) private var othersCollapsed = false // 1.5: 건강 타일 표시 여부 + 3섹션 순서 (기기 로컬 — LocalPrefs·plan §3.3) @AppStorage(LocalPrefsKeys.healthTilesEnabled, store: AppGroup.defaults) private var healthTilesEnabled = true @AppStorage(LocalPrefsKeys.mainSectionOrder, store: AppGroup.defaults) private var mainSectionOrderRaw = "" private var orderedMainSections: [MainSectionKind] { MainSectionKind.orderedList(raw: mainSectionOrderRaw) } /// 건강 섹션 노출 판정 — 설정 켬 + 이 기기에서 건강 데이터 사용 가능(맥은 항상 숨김) private var showsHealthSection: Bool { healthTilesEnabled && HealthDataStore.isAvailable } private var orderedActions: [Action] { LocalPrefs.orderedActions(actions, raw: actionOrderRaw) } /// 즐겨찾기 행동 (기기별 배치 순서 유지). 별도 카드가 아니라 그리드의 첫 섹션으로 노출 — /// v1(가로 스크롤 컴팩트 카드 줄)은 그리드와 셀 크기·배경이 달라 "따로 노는 느낌"이라는 /// 실사용 피드백으로 교체됨. 숨기는 행동 없이 위치만 맨 위로 승격되고, 아래 '나머지 행동' /// 섹션은 접어 둘 수 있어 행동이 많아도 자주 쓰는 것만 남는다 (CLAUDE.md §6.1) private var favoriteActions: [Action] { orderedActions.filter(\.isFavorite) } /// 즐겨찾기 밖 행동 — 즐겨찾기가 하나라도 있을 때만 별도 섹션(접기 가능)으로 나뉜다 private var otherActions: [Action] { orderedActions.filter { !$0.isFavorite } } 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 favoriteZoneTargeted = false @State private var otherZoneTargeted = false @State private var showingAddAction = false @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 // 행동이 없어도 건강 섹션은 배치 (설정에서 켠 경우 — 1.5) if showsHealthSection { HealthSectionView() } } else if isEditing { // 편집도 같은 섹션 구조 — 섹션 안 드래그=순서 변경, 섹션 사이 드래그=지정·해제. // 건강 섹션은 편집(배치) 모드에서 숨김 — 드롭 델리게이트와의 간섭 원천 차단 (plan §10 R4) editingFavoritesSection editingOthersSection } else { // 1.5: 즐겨찾기·나머지·건강 3섹션을 설정 순서대로 배치 (§6.1 — // 기존 섹션 빌더·그리드는 무변경, 순서만 이 루프가 결정) ForEach(orderedMainSections) { kind in switch kind { case .favorites: if favoriteActions.isEmpty { // 즐겨찾기가 없으면 통짜 그리드가 행동 자리를 담당 (기존 동작 그대로) grid } else { favoritesSection } case .others: if !favoriteActions.isEmpty && !otherActions.isEmpty { othersSection } case .health: if showsHealthSection { HealthSectionView() } } } } } .padding() } .background(AppTheme.background) .navigationTitle("모음") .toolbar { ToolbarItem(placement: .topBarLeading) { // 다른 기기에서 바뀐 데이터를 앱 재시작 없이 다시 불러오는 새로고침. // 프리미엄 전용 숨김 판정은 RefreshButton body 안에서 한다 — // toolbar 클로저의 if는 @Observable 변경 시 재평가가 보장되지 않음(AppRefresh.swift 참고) 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(isPresented: $showingAddAction) { ActionEditorView(action: nil) } .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) } } // 검증용: -recordAddFor "행동이름" → 롱프레스 '기록 직접 입력·수정'과 동일한 시트 if let name = UserDefaults.standard.string(forKey: "recordAddFor"), let action = actions.first(where: { $0.name == name }) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { recordsAction = action } } } #endif } // MARK: 목표 진행 현황 영역 (1개=단독 카드, 2개 이상=한 장씩 스냅 페이징 가로 스크롤) @ViewBuilder private var goalArea: some View { if DeviceLayout.isPad { // 넓은 화면: 페이징 대신 카드를 여러 장 나란히 배치 (폭도 카드 단위로 제한). // 비-lazy 레이아웃인 이유는 AdaptiveColumnsLayout 주석 참고 (스크롤 진동 버그) // 맥은 아이패드 포인트가 축소 매핑돼 카드가 작아 보임 — 폭을 키워 보정 (§6.1) AdaptiveColumnsLayout( minWidth: DeviceLayout.isMac ? 430 : 340, maxWidth: DeviceLayout.isMac ? 640 : 520, 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: 즐겨찾기·나머지 섹션 (행동이 많아도 자주 쓰는 것만 위에 남기기) /// 접힘 상태 — DEBUG 스크린샷 검증용 오버라이드(-mainOthersCollapsed, 실 설정 무변경) 포함 private var effectiveOthersCollapsed: Bool { #if DEBUG if UserDefaults.standard.bool(forKey: "mainOthersCollapsed") { return true } #endif return othersCollapsed } private var favoritesHeader: some View { Label { Text("즐겨찾기") } icon: { Image(systemName: "star.fill") .foregroundStyle(AppTheme.yellow) } .font(.subheadline.weight(.semibold)) .foregroundStyle(AppTheme.green) .padding(.leading, 2) } private var favoritesSection: some View { VStack(alignment: .leading, spacing: 10) { favoritesHeader actionGrid(favoriteActions) } } // MARK: 배치 편집용 섹션 (편집 중에도 같은 구조 — 항상 펼침, 접기 버튼 없음) private var editingFavoritesSection: some View { VStack(alignment: .leading, spacing: 10) { favoritesHeader if favoriteActions.isEmpty { favoriteDropZone( makeFavorite: true, targeted: $favoriteZoneTargeted, icon: "star", hint: Text("행동을 여기로 끌면 즐겨찾기로 지정돼요") ) } else { actionGrid(favoriteActions) } } } private var editingOthersSection: some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 6) { Text("나머지 행동") .font(.subheadline.weight(.semibold)) .foregroundStyle(AppTheme.green) if !otherActions.isEmpty { Text("\(otherActions.count)개") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) } } .padding(.leading, 2) if otherActions.isEmpty { favoriteDropZone( makeFavorite: false, targeted: $otherZoneTargeted, icon: "star.slash", hint: Text("행동을 여기로 끌면 즐겨찾기에서 빠져요") ) } else { actionGrid(otherActions) } } } /// 빈 섹션의 점선 드롭 존 — 셀이 하나도 없으면 드롭 대상이 없어지므로 이 존이 받는다 private func favoriteDropZone(makeFavorite: Bool, targeted: Binding, icon: String, hint: Text) -> some View { HStack(spacing: 8) { Image(systemName: icon) hint } .font(.footnote.weight(.medium)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, minHeight: 72) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(targeted.wrappedValue ? AppTheme.yellow.opacity(0.10) : Color.clear) ) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder( targeted.wrappedValue ? AppTheme.yellow : Color.secondary.opacity(0.35), style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]) ) ) .onDrop(of: [UTType.text], delegate: FavoriteZoneDropDelegate( dragging: $draggingAction, targeted: targeted, perform: { dropOnZone(makeFavorite: makeFavorite) } )) } private var othersSection: some View { VStack(alignment: .leading, spacing: 10) { Button { withAnimation(.smooth(duration: 0.25)) { othersCollapsed.toggle() } } label: { HStack(spacing: 6) { Text("나머지 행동") .font(.subheadline.weight(.semibold)) .foregroundStyle(AppTheme.green) Text("\(otherActions.count)개") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) Spacer(minLength: 0) Image(systemName: "chevron.down") .font(.caption.weight(.bold)) .foregroundStyle(.secondary) .rotationEffect(.degrees(effectiveOthersCollapsed ? -90 : 0)) } .padding(.leading, 2) .contentShape(.rect) } .buttonStyle(.plain) .accessibilityLabel(effectiveOthersCollapsed ? Text("나머지 행동 펼치기") : Text("나머지 행동 접기")) if !effectiveOthersCollapsed { actionGrid(otherActions) } } } // 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는 AdaptiveColumnsLayout이 자동 채움) private var gridLayout: [GridItem] { Array(repeating: GridItem(.flexible(), spacing: 12), count: gridColumns) } /// 편집 중·즐겨찾기 없음일 때의 통짜 그리드 (기존 동작 그대로) private var grid: some View { actionGrid(orderedActions) } /// iPad는 목표 카드 영역과 같은 비-lazy 적응형 레이아웃 (통일 + 스크롤 진동 방지). /// 즐겨찾기·나머지 섹션이 같은 빌더를 쓰므로 셀 크기·열 규칙이 항상 그리드와 동일하다 private func actionGrid(_ list: [Action]) -> some View { Group { if DeviceLayout.isPad { // 맥은 축소 매핑 보정으로 버튼 폭을 키운다 (§6.1) AdaptiveColumnsLayout( minWidth: DeviceLayout.isMac ? 200 : 150, maxWidth: DeviceLayout.isMac ? 280 : 220, spacing: 12 ) { ForEach(list) { action in cell(for: action) } } } else { LazyVGrid(columns: gridLayout, spacing: 12) { ForEach(list) { action in cell(for: action) } } } } .animation(.default, value: list.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:), commit: commitDrop(on:) ) ) } else { base .contextMenu { actionContextMenu(for: action) } } } /// 행동 셀 롱프레스 메뉴 — 모음 탭 즐겨찾기·나머지 섹션과 통짜 그리드가 공유 @ViewBuilder private func actionContextMenu(for action: Action) -> some View { 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 { action.isFavorite.toggle() // 새 즐겨찾기는 배치 순서상 즐겨찾기 블록 끝으로 — "지정한 순서대로 뒤에 붙는" 규칙 (§6.1) if action.isFavorite { LocalPrefs.placeNewFavoriteAtEnd(action, context: context) } DataChange.commit(context: context) } label: { Label( action.isFavorite ? String(localized: "즐겨찾기 해제") : String(localized: "즐겨찾기"), systemImage: action.isFavorite ? "star.slash" : "star.fill" ) } 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("습관이나 할 일을 '행동'으로 등록하면\n버튼 한 번으로 시간과 횟수를 기록할 수 있어요.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) VStack(spacing: 10) { Button { showingAddAction = true } label: { Label("첫 행동 만들기", systemImage: "plus") .padding(.horizontal, 10) } .buttonStyle(.borderedProminent) .buttonBorderShape(.capsule) .tint(AppTheme.green) Button { createStarterPack() } label: { Text("예시 행동으로 시작해 보기") .font(.subheadline) } .tint(AppTheme.green) } .padding(.top, 8) } .frame(maxWidth: .infinity) .padding(.vertical, 60) } /// 빈 상태에서 바로 써 볼 수 있는 예시 세트 (실사용 가능한 구성 — 언제든 수정·삭제 가능) private func createStarterPack() { guard actions.isEmpty else { return } let life = Tag(name: String(localized: "생활"), colorHex: "#2F6B4F") let health = Tag(name: String(localized: "건강"), colorHex: "#4A7A9D") life.sortOrder = 0 health.sortOrder = 1 context.insert(life) context.insert(health) let reading = Action(name: String(localized: "독서"), symbolName: "book.fill", trackingType: .time, sortOrder: 0) reading.tags = [life] let workout = Action(name: String(localized: "운동"), symbolName: "figure.run", trackingType: .time, sortOrder: 1) workout.tags = [health] let water = Action(name: String(localized: "물 마시기"), symbolName: "drop.fill", trackingType: .count, sortOrder: 2) water.tags = [health] for action in [reading, workout, water] { context.insert(action) LocalPrefs.appendActionToOrder(action.uuid) } DataChange.commit(context: context) } // 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 } // 교차 섹션 호버는 실시간 이동 금지 — 소속(isFavorite)이 안 바뀐 채 전역 순서만 바뀌면 // 자기 섹션 안에서 엉뚱한 위치로 점프해 보인다. 교차 이동은 드롭 확정 때 처리(commitDrop) guard dragging.isFavorite == target.isFavorite 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)) } /// 드롭 확정 — 다른 섹션의 셀 위에 놓았으면 즐겨찾기 상태를 목적지 섹션에 맞추고 그 위치로 이동 private func commitDrop(on target: Action) { guard let dragging = draggingAction, dragging != target, dragging.isFavorite != target.isFavorite else { return } dragging.isFavorite = target.isFavorite var ordered = orderedActions if let from = ordered.firstIndex(of: dragging) { ordered.remove(at: from) if let to = ordered.firstIndex(of: target) { ordered.insert(dragging, at: to) } else { ordered.append(dragging) } actionOrderRaw = LocalPrefs.rawValue(ordered.map(\.uuid)) } // isFavorite는 모델 필드(기기 간 동기화·워치 즐겨찾기) — 저장 + 위젯·워치 갱신 경로 DataChange.commit(context: context) } /// 빈 섹션의 점선 존에 드롭 — 소속만 바꾼다 (섹션에 하나뿐이라 위치는 자명) private func dropOnZone(makeFavorite: Bool) { guard let dragging = draggingAction, dragging.isFavorite != makeFavorite else { return } dragging.isFavorite = makeFavorite if makeFavorite { LocalPrefs.placeNewFavoriteAtEnd(dragging, context: context) } DataChange.commit(context: context) } } // 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(safeSymbol: 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(safeSymbol: 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 restDay = span == .day && !progress.isScheduled(on: .now) // 주기 몫을 이미 채운 주/월 다짐은 하루 0% 대신 완료 상태 (§4.2 주기 몫 완료) let fulfilled = span == .day && !restDay && progress.isPeriodFulfilled() let result = progress.spanProgress(span) let over = !restDay && quest.direction == .atMost && result.value > result.target miniGauge( label: span.label, ratio: restDay ? 0 : (fulfilled ? 1 : result.ratio), percentText: restDay ? String(localized: "수행일 아님") : (fulfilled ? progress.periodFulfilledLabel : Format.percent(result.displayRatio)), color: over ? .red : quest.targetColor, emphasized: over, showsCheck: fulfilled ) } } } } /// 표시 방식 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, showsCheck: Bool = false ) -> some View { VStack(alignment: .leading, spacing: 2) { Text(label) .font(.caption2) .foregroundStyle(.secondary) ProgressView(value: min(max(ratio, 0), 1)) .tint(color) HStack(spacing: 3) { if showsCheck { Image(systemName: "checkmark.circle.fill") .font(.caption2) .foregroundStyle(AppTheme.green) } Text(percentText) .font(.caption2.weight(.semibold).monospacedDigit()) .foregroundStyle(emphasized ? .red : .secondary) } } .frame(maxWidth: .infinity) } } // MARK: - 측정 종료 메모 시트 struct SessionMemoSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var context 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(safeSymbol: 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) try? context.save() dismiss() } .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } } .presentationDetents([.height(280)]) .onAppear { text = session.note } } } // MARK: - 횟수 기록 메모 시트 struct CountMemoSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var context 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(safeSymbol: 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) try? context.save() 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 let commit: (Action) -> Void func dropEntered(info: DropInfo) { move(item) // 같은 섹션이면 실시간 순서 이동 (교차 섹션 호버는 move가 무시) } func dropUpdated(info: DropInfo) -> DropProposal? { DropProposal(operation: .move) } func performDrop(info: DropInfo) -> Bool { commit(item) // 교차 섹션 드롭이면 즐겨찾기 지정·해제 + 그 위치로 이동 dragging = nil return true } } /// 빈 섹션의 점선 드롭 존 전용 — 하이라이트 표시 + 드롭 시 지정·해제 private struct FavoriteZoneDropDelegate: DropDelegate { @Binding var dragging: Action? @Binding var targeted: Bool let perform: () -> Void func dropEntered(info: DropInfo) { targeted = true } func dropExited(info: DropInfo) { targeted = false } func dropUpdated(info: DropInfo) -> DropProposal? { DropProposal(operation: .move) } func performDrop(info: DropInfo) -> Bool { perform() targeted = false 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(safeSymbol: 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) .accessibilityLabel(Text("측정 종료")) } } } // MARK: - iPad 적응형 그리드 (비-lazy) /// iPad 모음 탭 전용: `LazyVGrid(.adaptive)` 대체. /// lazy 컨테이너는 화면 밖 항목의 높이를 "추정"하는데, 높이가 제각각인 목표 카드가 /// 화면 밖으로 나가면 실제 높이와 추정이 어긋나 ScrollView 콘텐츠 높이가 출렁이고, /// 1초 타이머 갱신이 이를 계속 걷어차 스크롤이 무한 진동하는 실기기 버그가 있었다 /// (아이폰은 고정 열+측정 높이 고정이라 무관). 항목 수가 적으므로 전부 즉시 배치해 /// 높이 추정을 원천 제거한다. 열 계산은 LazyVGrid(.adaptive)와 동일 규칙. struct AdaptiveColumnsLayout: Layout { var minWidth: CGFloat var maxWidth: CGFloat var spacing: CGFloat private func columns(for width: CGFloat) -> (count: Int, itemWidth: CGFloat) { guard width > minWidth else { return (1, max(width, 1)) } let count = max(1, Int((width + spacing) / (minWidth + spacing))) let itemWidth = min((width - spacing * CGFloat(count - 1)) / CGFloat(count), maxWidth) return (count, itemWidth) } /// 항목들을 행 단위로 잘라 (행 시작 인덱스, 행 높이)를 계산 private func rows(width: CGFloat, subviews: Subviews) -> (itemWidth: CGFloat, count: Int, heights: [CGFloat]) { let (count, itemWidth) = columns(for: width) var heights: [CGFloat] = [] var index = 0 while index < subviews.count { let rowEnd = min(index + count, subviews.count) var rowHeight: CGFloat = 0 for i in index.. CGSize { let width = proposal.width ?? minWidth let layout = rows(width: width, subviews: subviews) let height = layout.heights.reduce(0, +) + spacing * CGFloat(max(layout.heights.count - 1, 0)) return CGSize(width: width, height: height) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let layout = rows(width: bounds.width, subviews: subviews) var y = bounds.minY var index = 0 var row = 0 while index < subviews.count { let rowEnd = min(index + layout.count, subviews.count) var x = bounds.minX for i in index.. 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(safeSymbol: 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) // 시간형·횟수형 셀의 자연 높이 통일: 가장 큰 푸터(횟수형 숫자)의 높이를 // 보이지 않는 템플릿으로 두 유형 모두에 확보한다 — 셀 고유 높이를 그대로 쓰는 // 아이패드 AdaptiveColumnsLayout에서 유형별로 버튼 높이가 달라지는 것 방지 ZStack(alignment: .bottomLeading) { Text(verbatim: "0") .font(.system(size: compact ? 18 : 27, weight: .heavy, design: .rounded)) .hidden() .accessibilityHidden(true) 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) // 보이스오버: 셀 전체를 "이름 + 오늘 값" 한 덩어리로 읽는다 (아이콘·타이머 개별 낭독 방지) .accessibilityLabel(Text(action.name)) .accessibilityValue(Text(accessibilityValueText)) } /// 보이스오버용 현재 상태 문구 (측정 중이면 상태, 아니면 오늘 누적) private var accessibilityValueText: String { switch action.trackingType { case .time: return action.isRunning ? String(localized: "측정 중") : todayLabel case .count: return String(localized: "오늘 \(todayCount)회") } } @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(Format.countUnit(todayCount)) .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 ) } }