mycode/myApp/HaruDanim/IOS/Views/MainView.swift
songyc macbook 0e6804b531 feat(progress): 주기 몫 채운 주간·월간 다짐의 하루 진행률을 '이번 주/달 달성' 상태로 표시
- 주간/월간 '이상 달성' 다짐이 현재 주기 몫을 채우면 하루 게이지가 0% 대신
  완료 상태(게이지 가득+초록 ✓+문구)로 표시 — 목표 탭 행·모음 카드·위젯 ②(체크)·
  ③(문구)·④(링 가득)·시리 하루 응답 공유
- 목표 하루 평균(combinedSpanRatio)에도 해당 다짐을 1.0으로 반영 —
  위젯 ② 목표 바·워치 목표 게이지·시리 목표 응답·일기 헤더 자동 반영
- 판정은 QuestProgress.isPeriodFulfilled 단일 지점 — 기준일의 논리적 하루 끝
  클리핑으로 일기 과거 날짜 소급 차단. spanProgress 수치·판정(judgment)·연속(streak)
  불변(표시 계층 전용). 하루 다짐·특정 기간·atMost는 비대상, 잠금화면·워치
  컴플리케이션은 공간 제약으로 수치 유지
- 도움말 '진행률 읽는 법' 문구 갱신, l10n en/ja 완료(전 카탈로그 stale 0·missing 0)
- 검증: -progressSelfTest 37→49건 ALL PASS(기존 37건 수치 무변경), 신규
  -seedPeriodFulfilled 시드로 ko/en/ja·위젯 실렌더 확인, 시리 스모크 10건 OK,
  Debug/Store 빌드 성공(워치 타깃은 Shared 미포함·무변경)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVeduv1eNdXjk1ay4tSgBg
2026-08-01 06:45:01 +09:00

1343 lines
55 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
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
} else if isEditing {
// = , =·
editingFavoritesSection
editingOthersSection
} else if favoriteActions.isEmpty {
grid
} else {
favoritesSection
if !otherActions.isEmpty {
othersSection
}
}
}
.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(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)
// / 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(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
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(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 {
// 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(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)
// · : ( )
// 릿
// 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
)
}
}