- HealthMetric 8종(표준 단위·표기) + HealthStore(HK 조회: 누적 통계·스탠드·마음챙김· 수면 구간[합집합 병합, 깨어난 날 귀속 — plan §3.4 규칙]) + HealthCache(App Group, 지표×dayKey, 400일 보존 — 5.1.3 준수: CloudKit 비저장) - 모음 탭 건강 섹션(타일 줄+접기·연결 CTA[명시적 탭 권한]·안내 시트[사용자 요구 가이드]· 지표 선택 시트[체크+드래그 순서]) — actionGrid·레이아웃 금지구역 무접촉, 편집 모드 숨김 - 3섹션(즐겨찾기·나머지·건강) 순서 렌더러 + 설정 탭 표시 토글·순서 화면(기기 로컬) - HealthKit 엔타이틀먼트(iOS 앱만)·NSHealthShareUsageDescription, 전체 초기화에 건강 키·캐시 청소 추가 - 검증: Debug/Store 빌드, 자가 검증 106건 ALL PASS(26.5+18.5), 시각 QA 7장 (타일·순서·설정·안내·지표·CTA·18.5 빈 상태). 신규 인자 3종 §14 기록 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
1375 lines
57 KiB
Swift
1375 lines
57 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.createdAt) private var actions: [Action]
|
||
@Query(filter: #Predicate<TimeSession> { $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<Bool>, 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..<rowEnd {
|
||
let size = subviews[i].sizeThatFits(ProposedViewSize(width: itemWidth, height: nil))
|
||
rowHeight = max(rowHeight, size.height)
|
||
}
|
||
heights.append(rowHeight)
|
||
index = rowEnd
|
||
}
|
||
return (itemWidth, count, heights)
|
||
}
|
||
|
||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> 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..<rowEnd {
|
||
// LazyVGrid처럼 행 높이(행에서 가장 큰 셀)를 제안해 같은 행의 셀 높이를 맞춘다
|
||
// (행동 셀은 내부 Spacer로 늘어나고, 고정 높이 프레임의 목표 카드는 무영향).
|
||
// 측정(sizeThatFits)에는 관여하지 않으므로 스크롤 진동 버그와 무관.
|
||
subviews[i].place(
|
||
at: CGPoint(x: x, y: y),
|
||
anchor: .topLeading,
|
||
proposal: ProposedViewSize(width: layout.itemWidth, height: layout.heights[row])
|
||
)
|
||
x += layout.itemWidth + spacing
|
||
}
|
||
y += layout.heights[row] + spacing
|
||
row += 1
|
||
index = rowEnd
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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(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
|
||
)
|
||
}
|
||
}
|