- 아이패드 모음 탭에서 맨 아래로 스크롤하면 화면이 위아래로 무한 진동하며 터치가 안 먹는 실기기 버그 — lazy 컨테이너의 화면 밖 높이 "추정"이 높이 제각각인 목표 카드의 실제 높이와 어긋나 콘텐츠 높이가 출렁이고, 1초 타이머 갱신이 매초 이를 걷어차 진동이 지속되는 레이아웃 피드백 루프로 분석 - 수정: iPad 전용 비-lazy `AdaptiveColumnsLayout`(커스텀 Layout) 신설 — LazyVGrid(.adaptive)와 동일한 열 계산(목표 340~520·행동 150~220, 행 내 위 정렬·왼쪽 정렬)으로 시각 결과는 그대로 두고 전 항목 즉시 배치로 높이 추정을 원천 제거. 항목 수가 적어 성능 부담 없음 - 목표 카드 영역 + 행동 그리드 둘 다 적용(통일, 사용자 지시). iPhone 경로(고정 열 LazyVGrid·페이징)는 무변경 - 검증: iPad 모음(카드·그리드·행 배치)·배치 편집 모드·iPhone 모음 시뮬 스크린샷 — 기존과 동일 렌더. 진동 재현은 스크롤 제스처 자동화 불가로 실기기 확인 필요(같은 절차: 측정 여러 개 켠 상태로 맨 아래 스크롤) - 새 문자열 없음(카탈로그 클린), 도움말 무변경. CLAUDE.md §6.1에 원인·재발 방지(다시 lazy로 되돌리지 말 것) 기재. Debug/Store 빌드 성공 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
1077 lines
43 KiB
Swift
1077 lines
43 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 = ""
|
||
|
||
private var orderedActions: [Action] {
|
||
LocalPrefs.orderedActions(actions, raw: actionOrderRaw)
|
||
}
|
||
|
||
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 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
|
||
} else {
|
||
grid
|
||
}
|
||
}
|
||
.padding()
|
||
}
|
||
.background(AppTheme.background)
|
||
.navigationTitle("모음")
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) {
|
||
// 다른 기기에서 바뀐 데이터를 앱 재시작 없이 다시 불러오는 새로고침
|
||
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 주석 참고 (스크롤 진동 버그)
|
||
AdaptiveColumnsLayout(minWidth: 340, maxWidth: 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: 배치 편집 컨트롤 (한 줄 개수 선택)
|
||
|
||
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)
|
||
}
|
||
|
||
/// iPad는 목표 카드 영역과 같은 비-lazy 적응형 레이아웃 (통일 + 스크롤 진동 방지)
|
||
private var grid: some View {
|
||
Group {
|
||
if DeviceLayout.isPad {
|
||
AdaptiveColumnsLayout(minWidth: 150, maxWidth: 220, spacing: 12) {
|
||
gridCells
|
||
}
|
||
} else {
|
||
LazyVGrid(columns: gridLayout, spacing: 12) {
|
||
gridCells
|
||
}
|
||
}
|
||
}
|
||
.animation(.default, value: orderedActions.map(\.persistentModelID))
|
||
.animation(.default, value: gridColumns)
|
||
}
|
||
|
||
private var gridCells: some View {
|
||
ForEach(orderedActions) { action in
|
||
cell(for: action)
|
||
}
|
||
}
|
||
|
||
@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:)
|
||
)
|
||
)
|
||
} else {
|
||
base
|
||
.contextMenu {
|
||
Button {
|
||
// 기록 탭으로 이동해 이 행동만 필터링된 상태로 표시
|
||
router.openHistory(
|
||
filtering: action.persistentModelID,
|
||
visibleTabsRaw: visibleTabsRaw
|
||
)
|
||
} label: {
|
||
Label("기록 확인", systemImage: "list.bullet.rectangle")
|
||
}
|
||
Button {
|
||
// 통계 탭으로 이동해 이 행동만 필터링된 상태로 표시
|
||
router.openStats(
|
||
filtering: action.persistentModelID,
|
||
visibleTabsRaw: visibleTabsRaw
|
||
)
|
||
} label: {
|
||
Label("통계 보기", systemImage: "chart.xyaxis.line")
|
||
}
|
||
Button {
|
||
recordsAction = action
|
||
} label: {
|
||
Label(
|
||
action.trackingType == .time ? String(localized: "시작·종료 시각 수동 입력") : String(localized: "횟수 직접 입력·수정"),
|
||
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("습관이나 할 일을 '행동'으로 등록하면\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 }
|
||
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))
|
||
}
|
||
}
|
||
|
||
// 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(systemName: goal.symbolName)
|
||
.font(.system(size: 15, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 32, height: 32)
|
||
.background(goal.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text("목표 진행 현황")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
Text(goal.title)
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(.primary)
|
||
.lineLimit(1)
|
||
}
|
||
Spacer()
|
||
Image(systemName: "chevron.right")
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
if goal.status == .inProgress, let progress = goal.dateProgress() {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
ProgressView(value: progress)
|
||
.tint(goal.color)
|
||
Text("기간 진행률 \(Format.percent(progress))")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
if !goal.sortedQuests.isEmpty {
|
||
// 설정의 표시 방식이 선택된 모든 목표 카드에 동일하게 적용됨
|
||
switch cardStyle {
|
||
case .perQuest:
|
||
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(systemName: quest.targetSymbol)
|
||
.font(.system(size: 9, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 18, height: 18)
|
||
.background(quest.targetColor, in: RoundedRectangle(cornerRadius: 5, style: .continuous))
|
||
Text(quest.targetName)
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.primary)
|
||
.lineLimit(1)
|
||
}
|
||
HStack(spacing: 10) {
|
||
ForEach(StatSpan.allCases) { span in
|
||
// 오늘이 수행일이 아니면 하루 게이지는 수치 대신 상태 표기 (목표 탭 행과 동일 규칙)
|
||
let restDay = span == .day && !progress.isScheduled(on: .now)
|
||
let result = progress.spanProgress(span)
|
||
let over = !restDay && quest.direction == .atMost && result.value > result.target
|
||
miniGauge(
|
||
label: span.label,
|
||
ratio: restDay ? 0 : result.ratio,
|
||
percentText: restDay ? String(localized: "수행일 아님") : Format.percent(result.displayRatio),
|
||
color: over ? .red : quest.targetColor,
|
||
emphasized: over
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 표시 방식 B: 소속 다짐 전체의 하루/주간/월간 진행률 평균을 한 줄로 표시
|
||
private var combinedBlock: some View {
|
||
HStack(spacing: 10) {
|
||
ForEach(StatSpan.allCases) { span in
|
||
let ratio = combinedRatio(span)
|
||
miniGauge(
|
||
label: "\(span.label) 전체",
|
||
ratio: ratio,
|
||
percentText: Format.percent(ratio),
|
||
color: goal.color,
|
||
emphasized: false
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 소속 다짐들의 span 진행률 평균 (이하 유지 다짐은 100% 또는 0%로 반영됨)
|
||
private func combinedRatio(_ span: StatSpan) -> Double {
|
||
goal.combinedSpanRatio(span)
|
||
}
|
||
|
||
private func miniGauge(
|
||
label: String,
|
||
ratio: Double,
|
||
percentText: String,
|
||
color: Color,
|
||
emphasized: Bool
|
||
) -> some View {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(label)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
ProgressView(value: min(max(ratio, 0), 1))
|
||
.tint(color)
|
||
Text(percentText)
|
||
.font(.caption2.weight(.semibold).monospacedDigit())
|
||
.foregroundStyle(emphasized ? .red : .secondary)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
|
||
// MARK: - 측정 종료 메모 시트
|
||
|
||
struct SessionMemoSheet: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@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(systemName: action.symbolName)
|
||
.font(.system(size: 15, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 32, height: 32)
|
||
.background(action.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(action.name)
|
||
.font(.subheadline.weight(.semibold))
|
||
Text("\(Format.durationShort(session.duration())) 측정 완료")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
TextField("이번 측정에 대한 메모 (선택)", text: $text, axis: .vertical)
|
||
.lineLimit(3...5)
|
||
.padding(10)
|
||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||
.focused($focused)
|
||
Spacer()
|
||
}
|
||
.padding()
|
||
.background(AppTheme.background)
|
||
.navigationTitle("메모 남기기")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("건너뛰기") { dismiss() }
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("저장") {
|
||
session.note = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
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(systemName: action.symbolName)
|
||
.font(.system(size: 15, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 32, height: 32)
|
||
.background(action.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(action.name)
|
||
.font(.subheadline.weight(.semibold))
|
||
Text("+\(entry.amount) 기록 완료")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
TextField("이번 기록에 대한 메모 (선택)", text: $text, axis: .vertical)
|
||
.lineLimit(3...5)
|
||
.padding(10)
|
||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||
.focused($focused)
|
||
Spacer()
|
||
}
|
||
.padding()
|
||
.background(AppTheme.background)
|
||
.navigationTitle("메모 남기기")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("건너뛰기") { dismiss() }
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("저장") {
|
||
entry.note = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
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
|
||
|
||
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)
|
||
.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 {
|
||
// 높이는 각 항목 고유값 그대로, 행 안에서 위 정렬 (기존 GridItem alignment: .top과 동일)
|
||
subviews[i].place(
|
||
at: CGPoint(x: x, y: y),
|
||
anchor: .topLeading,
|
||
proposal: ProposedViewSize(width: layout.itemWidth, height: nil)
|
||
)
|
||
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(systemName: action.symbolName)
|
||
.font(.system(size: compact ? 17 : 26, weight: .medium))
|
||
.foregroundStyle(.white)
|
||
.symbolEffect(.bounce, value: bounce)
|
||
Spacer()
|
||
if action.isRunning && !compact {
|
||
Image(systemName: "record.circle")
|
||
.font(.system(size: 14, weight: .bold))
|
||
.foregroundStyle(AppTheme.yellow)
|
||
.symbolEffect(.pulse, options: .repeating)
|
||
}
|
||
}
|
||
Spacer(minLength: 0)
|
||
Text(action.name)
|
||
.font(compact ? .caption2.weight(.semibold) : .footnote.weight(.semibold))
|
||
.foregroundStyle(.white)
|
||
.lineLimit(compact ? 1 : 2)
|
||
.multilineTextAlignment(.leading)
|
||
footer
|
||
}
|
||
.padding(compact ? 8 : 12)
|
||
.frame(minHeight: compact ? 68 : 104, alignment: .topLeading)
|
||
.frame(maxWidth: .infinity)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||
.fill(action.color.gradient)
|
||
)
|
||
.overlay {
|
||
if action.isRunning {
|
||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||
.strokeBorder(AppTheme.yellow, lineWidth: compact ? 2 : 3)
|
||
}
|
||
}
|
||
}
|
||
.buttonStyle(PressableButtonStyle())
|
||
.sensoryFeedback(.impact(weight: .medium), trigger: bounce)
|
||
.disabled(isEditing)
|
||
// 보이스오버: 셀 전체를 "이름 + 오늘 값" 한 덩어리로 읽는다 (아이콘·타이머 개별 낭독 방지)
|
||
.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("회")
|
||
.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
|
||
)
|
||
}
|
||
}
|