mycode/myApp/HaruDanim/IOS/Views/GoalViews.swift
songyc macbook 42b95c5d7f feat(quest): consecutive-achievement display on quests, plus wording fixes
다짐 단위 '연속 달성' 표기 추가 (QuestProgress.streak → QuestStreakInfo):
- 하루 단위 다짐: 적용일만 세는 연속 계산 — 매일이면 연속 일수,
  '매주 수요일'이면 수요일들만 이어서 센다 (4주 연속 = 연속 4일)
- 주간/월간 다짐: 주기 전체 달성 여부로 연속 N주/N달
- 특정 기간 다짐: 반복이 없어 표시하지 않음
- 진행 중인 오늘/이번 주기는 '이상 달성'이면 미달이어도 끊기지 않고
  건너뛰며, 채웠으면 포함. '이하 유지'는 한도를 넘는 순간 끊김
- 성능: 기록을 논리적 하루 키로 1회 버킷팅해 O(기록수+일수),
  소급 상한 400일

표기(기존 디자인·데이터는 그대로 두고 추가만, 0이면 숨김):
- 목표 탭 다짐 행: 이름 옆 불꽃 + "연속 N일" 배지 (노랑)
- 위젯 ③ 다짐 진행률: 퍼센트 아래 한 줄
- 위젯 ④ 다짐 현황: 이름 아래 작은 글씨
- 위젯 ② 목표+다짐 구성: 퍼센트 아래 6.5pt — 연속 표기가 있을 때만
  행 높이 14→19 (적응 채움이 자동 반영)
- 문구는 '스트릭' 대신 "연속 N일/주/달" (en은 streak, ja는 N日連続)

문구 정정: '몇째 주 요일' → '몇 번째 요일' — 구현(weekdayOrdinal)은
달력의 주차가 아니라 "그 달의 n번째 ◯요일"이므로 편집 화면 픽커와
주기 요약 문구를 실제 동작에 맞게 수정 (동작 변경 없음). en/ja 번역 포함.

검증: 시드 데이터로 목표 탭 배지 손계산 대조(독서 연속 3일 — 3일 전
기록 없음 / 영상 시청 어제 한도 초과로 연속 1일 등 전부 일치),
위젯 ②③④ 미리보기 스크린샷으로 레이아웃 넘침 없음 확인,
missing: [], Debug·Store 스킴 빌드 성공.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-13 23:49:45 +09:00

712 lines
28 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.

//
// GoalViews.swift
// Haru_Danim
//
// : // + (CLAUDE.md §6.4)
//
import SwiftUI
import SwiftData
// MARK: -
struct GoalListView: View {
@Environment(\.modelContext) private var context
@Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal]
private let premium = PremiumManager.shared
@State private var showingAdd: Bool = {
#if DEBUG
// : -goalShowEditor YES
if UserDefaults.standard.bool(forKey: "goalShowEditor") { return true }
#endif
return false
}()
@State private var showLimitAlert = false
@State private var showingPremiumSheet = false
@State private var isReordering = false
@State private var editMode: EditMode = .inactive
// (CloudKit , LocalPrefs )
@AppStorage(LocalPrefsKeys.collapsedGoals, store: AppGroup.defaults) private var collapsedGoalsRaw = ""
/// ( )
private var activeGoals: [Goal] { goals.filter { $0.status == .inProgress } }
/// /
private var finishedGoals: [Goal] { goals.filter { $0.status != .inProgress } }
var body: some View {
#if DEBUG
// : -goalShowFinished YES ( )
if UserDefaults.standard.bool(forKey: "goalShowFinished") {
FinishedGoalListView()
} else {
goalList
}
#else
goalList
#endif
}
private var goalList: some View {
ScrollViewReader { proxy in
goalListContent(proxy: proxy)
}
}
private func goalListContent(proxy: ScrollViewProxy) -> some View {
List {
if isReordering {
Section {
ForEach(activeGoals) { goal in
reorderRow(goal)
}
.onMove(perform: moveGoals)
} footer: {
Text("오른쪽 핸들을 끌어서 순서를 바꾼 뒤 ‘완료’를 누르세요. 순서는 저장되어 유지돼요.")
}
} else {
ForEach(activeGoals) { goal in
Section {
NavigationLink {
GoalDetailView(goal: goal)
} label: {
GoalRow(goal: goal)
}
if !LocalPrefs.contains(goal.uuid, in: collapsedGoalsRaw) {
ForEach(goal.sortedQuests) { quest in
QuestRow(quest: quest)
}
}
} header: {
if !goal.quests.isEmpty {
collapseHeader(goal)
}
}
}
if activeGoals.isEmpty {
ContentUnavailableView(
"진행 중인 목표가 없어요",
systemImage: "flag.checkered",
description: Text(goals.isEmpty
? "큰 목표를 세우고, 그 안에 다짐을 추가해 보세요."
: "새 목표를 세우거나, 아래에서 완료된 목표를 확인해 보세요.")
)
}
if !finishedGoals.isEmpty {
Section {
NavigationLink {
FinishedGoalListView()
} label: {
HStack {
Label("완료된 목표 보기", systemImage: "checkmark.seal")
.foregroundStyle(.primary)
Spacer()
Text("\(finishedGoals.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.id("finishedLink")
}
}
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("목표")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if activeGoals.count > 1 {
Button(isReordering ? String(localized: "완료") : String(localized: "순서")) {
withAnimation {
isReordering.toggle()
editMode = isReordering ? .active : .inactive
}
}
}
}
ToolbarItem(placement: .topBarTrailing) {
if !isReordering {
Button {
if !premium.canAddGoal(currentCount: goals.count) {
showLimitAlert = true
} else {
showingAdd = true
}
} label: {
Image(systemName: "plus")
}
}
}
}
.environment(\.editMode, $editMode)
.sheet(isPresented: $showingAdd) {
GoalEditorView(goal: nil)
}
.alert("무료 사용 한도", isPresented: $showLimitAlert) {
Button("프리미엄 알아보기") { showingPremiumSheet = true }
Button("확인", role: .cancel) {}
} message: {
Text("무료 버전에서는 목표를 최대 \(FreeLimits.goals)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
}
.sheet(isPresented: $showingPremiumSheet) {
PremiumSheetView()
}
.onAppear {
let math = DayMath()
var statusChanged = false
for goal in goals {
let before = goal.status
goal.evaluateIfEnded(math: math)
if goal.status != before { statusChanged = true }
}
if statusChanged { DataChange.commit(context: context) }
#if DEBUG
// : -goalReorder YES , -goalScrollBottom YES
if UserDefaults.standard.bool(forKey: "goalReorder") {
isReordering = true
editMode = .active
}
if UserDefaults.standard.bool(forKey: "goalScrollBottom") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { proxy.scrollTo("finishedLink", anchor: .bottom) }
}
}
#endif
}
}
///
private func reorderRow(_ goal: Goal) -> some View {
HStack(spacing: 10) {
Image(systemName: goal.symbolName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(goal.color, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
Text(goal.title)
.font(.subheadline.weight(.medium))
.lineLimit(1)
}
}
private func moveGoals(from source: IndexSet, to destination: Int) {
var ordered = activeGoals
ordered.move(fromOffsets: source, toOffset: destination)
for (index, goal) in ordered.enumerated() {
goal.sortOrder = index
}
DataChange.commit(context: context)
}
/// /
private func collapseHeader(_ goal: Goal) -> some View {
let collapsed = LocalPrefs.contains(goal.uuid, in: collapsedGoalsRaw)
return Button {
withAnimation(.spring(duration: 0.3)) {
collapsedGoalsRaw = LocalPrefs.toggling(goal.uuid, in: collapsedGoalsRaw)
}
} label: {
HStack(spacing: 4) {
Text("다짐 \(goal.quests.count)")
Image(systemName: collapsed ? "chevron.down" : "chevron.up")
.font(.caption2.weight(.semibold))
}
.font(.caption)
.foregroundStyle(AppTheme.green)
}
.buttonStyle(.plain)
.textCase(nil)
}
}
// MARK: - ( )
struct GoalRow: View {
@Environment(\.modelContext) private var context
let goal: Goal
@State private var showingConfirm = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Image(systemName: goal.symbolName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
.background(goal.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text(goal.title)
.font(.body.weight(.semibold))
Text(periodLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
statusBadge
}
if goal.needsConfirmation() {
Button {
showingConfirm = true
} label: {
Label("달성했나요? 탭해서 선택", systemImage: "questionmark.circle.fill")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.yellow)
}
.buttonStyle(.plain)
} else 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)
}
}
}
.padding(.vertical, 2)
.alert("목표 확인", isPresented: $showingConfirm) {
Button("달성했어요") {
goal.status = .achieved
DataChange.commit(context: context)
}
Button("달성하지 못했어요", role: .destructive) {
goal.status = .notAchieved
DataChange.commit(context: context)
}
Button("취소", role: .cancel) {}
} message: {
Text("\(goal.title) 목표를 달성했나요?")
}
}
private var periodLabel: String {
if let end = goal.endDate {
return "\(Format.shortDate(goal.startDate)) ~ \(Format.shortDate(end))"
}
return String(localized: "\(Format.shortDate(goal.startDate)) 시작 · 종료일 없음")
}
@ViewBuilder
private var statusBadge: some View {
let (text, color): (String, Color) = {
if goal.needsConfirmation() { return (String(localized: "확인 필요"), AppTheme.yellow) }
switch goal.status {
case .inProgress: return (String(localized: "진행 중"), AppTheme.green)
case .achieved: return (String(localized: "달성"), AppTheme.green)
case .notAchieved: return (String(localized: "미달성"), Color.secondary)
}
}()
Text(text)
.font(.caption2.weight(.bold))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color.opacity(0.15), in: Capsule())
.foregroundStyle(color)
}
}
// MARK: - (// )
struct QuestRow: View {
let quest: Quest
/// (1 )
private var streakLabel: String? {
guard let streak = QuestProgress(quest: quest).streak(), streak.count > 0 else { return nil }
return streak.label
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: quest.targetSymbol)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 24, height: 24)
.background(quest.targetColor, in: RoundedRectangle(cornerRadius: 6, style: .continuous))
Text(quest.targetName)
.font(.subheadline.weight(.medium))
if let streakLabel {
HStack(spacing: 2) {
Image(systemName: "flame.fill")
.font(.system(size: 9, weight: .bold))
Text(streakLabel)
.font(.caption2.weight(.semibold))
}
.foregroundStyle(AppTheme.yellow)
.lineLimit(1)
.layoutPriority(1)
}
Spacer()
Text("\(quest.scheduleLabel) · \(quest.targetValueLabel) \(quest.direction.label)")
.font(.caption2)
.foregroundStyle(.secondary)
}
HStack(spacing: 10) {
ForEach(StatSpan.allCases) { span in
spanGauge(span)
}
}
}
.padding(.leading, 8)
.padding(.vertical, 2)
}
private func spanGauge(_ span: StatSpan) -> some View {
let progress = QuestProgress(quest: quest)
let result = progress.spanProgress(span)
let over = quest.direction == .atMost && result.value > result.target
return VStack(alignment: .leading, spacing: 2) {
Text(span.label)
.font(.caption2)
.foregroundStyle(.secondary)
ProgressView(value: result.ratio)
.tint(over ? .red : quest.targetColor)
Text(Format.percent(result.displayRatio))
.font(.caption2.weight(.semibold).monospacedDigit())
.foregroundStyle(over ? .red : .primary)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - (/ )
struct FinishedGoalListView: View {
@Query(
filter: #Predicate<Goal> { $0.statusRaw != "inProgress" },
sort: \Goal.createdAt, order: .reverse
) private var goals: [Goal]
var body: some View {
List {
ForEach(goals) { goal in
Section {
NavigationLink {
GoalDetailView(goal: goal)
} label: {
GoalRow(goal: goal)
}
}
}
if goals.isEmpty {
ContentUnavailableView(
"완료된 목표가 없어요",
systemImage: "checkmark.seal",
description: Text("달성하거나 종료한 목표가 여기에 모여요.")
)
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("완료된 목표")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
}
}
// MARK: -
struct GoalDetailView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
private let premium = PremiumManager.shared
let goal: Goal
@State private var showingEdit = false
@State private var showingDelete = false
@State private var showingAddQuest = false
@State private var showQuestLimitAlert = false
@State private var showingPremiumSheet = false
@State private var showingManualFinish = false
@State private var askManualResult = false
@State private var editingQuest: Quest?
@State private var editMode: EditMode = .inactive
var body: some View {
List {
Section("목표") {
GoalRow(goal: goal)
}
Section {
ForEach(goal.sortedQuests) { quest in
Button {
editingQuest = quest
} label: {
QuestRow(quest: quest)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions {
Button("삭제", role: .destructive) {
context.delete(quest)
DataChange.commit(context: context)
}
}
}
.onMove { source, destination in
var ordered = goal.sortedQuests
ordered.move(fromOffsets: source, toOffset: destination)
for (index, quest) in ordered.enumerated() {
quest.sortOrder = index
}
DataChange.commit(context: context)
}
Button {
if !premium.canAddQuest(currentCount: goal.quests.count) {
showQuestLimitAlert = true
} else {
showingAddQuest = true
}
} label: {
Label("다짐 추가", systemImage: "plus.circle")
.foregroundStyle(AppTheme.green)
}
} header: {
Text("다짐")
} footer: {
if goal.quests.isEmpty {
Text("이 목표를 이루기 위한 다짐(행동/꼬리표 + 주기 + 목표량)을 추가하세요.")
} else {
Text("오른쪽 위 ‘순서’를 누르면 다짐 순서를 바꿀 수 있어요.")
}
}
if goal.status == .inProgress && goal.endDate == nil {
Section {
Button("목표 수동 종료") {
showingManualFinish = true
}
.foregroundStyle(AppTheme.yellow)
} footer: {
if goal.achieveThresholdPercent < 100 {
Text("종료 시 다짐 평균 달성률이 \(goal.achieveThresholdPercent)% 이상이면 달성으로 판정돼요.")
} else {
Text("종료 시 하위 다짐들의 달성 여부에 따라 달성/미달성이 결정돼요.")
}
}
}
Section {
Button("목표 삭제", role: .destructive) {
showingDelete = true
}
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle(goal.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if !goal.quests.isEmpty {
Button(editMode == .active ? String(localized: "완료") : String(localized: "순서")) {
withAnimation {
editMode = editMode == .active ? .inactive : .active
}
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("수정") { showingEdit = true }
}
}
.environment(\.editMode, $editMode)
.sheet(isPresented: $showingEdit) {
GoalEditorView(goal: goal)
}
.sheet(isPresented: $showingAddQuest) {
QuestEditorView(goal: goal, quest: nil)
}
.sheet(item: $editingQuest) { quest in
QuestEditorView(goal: goal, quest: quest)
}
.alert("무료 사용 한도", isPresented: $showQuestLimitAlert) {
Button("프리미엄 알아보기") { showingPremiumSheet = true }
Button("확인", role: .cancel) {}
} message: {
Text("무료 버전에서는 목표당 다짐을 최대 \(FreeLimits.questsPerGoal)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
}
.sheet(isPresented: $showingPremiumSheet) {
PremiumSheetView()
}
.alert("목표 수동 종료", isPresented: $showingManualFinish) {
Button("종료") {
if goal.manualFinish() == nil {
askManualResult = true
} else {
DataChange.commit(context: context)
}
}
Button("취소", role: .cancel) {}
} message: {
Text("목표를 지금 종료할까요?")
}
.alert("목표 확인", isPresented: $askManualResult) {
Button("달성했어요") {
goal.status = .achieved
DataChange.commit(context: context)
}
Button("달성하지 못했어요", role: .destructive) {
goal.status = .notAchieved
DataChange.commit(context: context)
}
Button("취소", role: .cancel) {}
} message: {
Text("다짐이 없는 목표예요. 달성했나요?")
}
.alert("목표 삭제", isPresented: $showingDelete) {
Button("삭제", role: .destructive) {
context.delete(goal)
DataChange.commit(context: context)
dismiss()
}
Button("취소", role: .cancel) {}
} message: {
Text("\(goal.title) 목표를 삭제할까요? 다짐도 함께 삭제됩니다.")
}
}
}
// MARK: - /
struct GoalEditorView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
/// nil
let goal: Goal?
@State private var title = ""
@State private var symbolName = "flag.fill"
@State private var color: Color = Color(hex: "#2F6B4F")
@State private var startDate: Date = .now
@State private var hasEndDate = false
@State private var endDate: Date = .now
@State private var achieveThreshold = 100
@State private var showingSymbolPicker = false
var body: some View {
NavigationStack {
Form {
Section("목표 내용") {
TextField("예: 토익 700점 이상 받기", text: $title, axis: .vertical)
}
Section("아이콘과 색") {
Button {
showingSymbolPicker = true
} label: {
HStack {
Image(systemName: symbolName)
.font(.system(size: 18))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
.background(color, in: RoundedRectangle(cornerRadius: 9))
Text("아이콘 선택")
.foregroundStyle(.primary)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
ColorPicker("", selection: $color, supportsOpacity: false)
}
Section("기간") {
DatePicker("시작일 (과거 가능)", selection: $startDate, displayedComponents: .date)
Toggle("종료일 지정", isOn: $hasEndDate.animation())
if hasEndDate {
DatePicker(
"종료일",
selection: $endDate,
in: startDate...,
displayedComponents: .date
)
}
}
Section {
HStack {
Text("다짐 평균 달성률")
Spacer()
Text(verbatim: "\(achieveThreshold)%")
.font(.body.weight(.semibold).monospacedDigit())
.foregroundStyle(achieveThreshold == 100 ? Color.secondary : AppTheme.green)
}
Slider(
value: Binding(
get: { Double(achieveThreshold) },
set: { achieveThreshold = Int($0.rounded()) }
),
in: 10...100,
step: 5
)
} header: {
Text("달성 판정 기준")
} footer: {
Text("목표가 끝날 때 다짐들의 평균 달성률이 이 값 이상이면 '달성 완료'로 판정해요. 100%면 모든 다짐을 채워야 달성이에요. 다짐이 없는 목표는 지금처럼 직접 선택해요.")
}
}
.navigationTitle(goal == nil ? String(localized: "목표 추가") : String(localized: "목표 수정"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") { save() }
.disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.sheet(isPresented: $showingSymbolPicker) {
SymbolPickerView(selection: $symbolName)
}
.onAppear(perform: load)
}
}
private func load() {
guard let goal else { return }
title = goal.title
symbolName = goal.symbolName
color = goal.color
startDate = goal.startDate
achieveThreshold = goal.achieveThresholdPercent
if let end = goal.endDate {
hasEndDate = true
endDate = end
}
}
private func save() {
let trimmed = title.trimmingCharacters(in: .whitespaces)
if let goal {
goal.title = trimmed
goal.symbolName = symbolName
goal.colorHex = color.hexString
goal.startDate = startDate
goal.endDate = hasEndDate ? endDate : nil
goal.achieveThresholdPercent = achieveThreshold
} else {
let newGoal = Goal(
title: trimmed,
symbolName: symbolName,
colorHex: color.hexString,
startDate: startDate,
endDate: hasEndDate ? endDate : nil
)
newGoal.achieveThresholdPercent = achieveThreshold
//
let existing = (try? context.fetch(FetchDescriptor<Goal>())) ?? []
newGoal.sortOrder = (existing.map(\.sortOrder).max() ?? -1) + 1
context.insert(newGoal)
}
DataChange.commit(context: context)
dismiss()
}
}