From 0509285a044a0b671e03f1c68d83488276e634d4 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Fri, 10 Jul 2026 04:41:11 +0900 Subject: [PATCH] feat(goals): add drag-and-drop reordering and hide completed goals - Implement drag-and-drop functionality for users to reorder the active goals list - Ensure the custom order of goals is persistently saved and loaded - Filter out completed goals from the main active list to reduce visual clutter - Add a 'View Completed Goals' button to navigate to a dedicated screen for finished items --- .../UserInterfaceState.xcuserstate | Bin 30001 -> 30001 bytes myApp/HaruDanim/IOS/Core/DebugSeed.swift | 27 +++ myApp/HaruDanim/IOS/Core/Models.swift | 4 +- myApp/HaruDanim/IOS/Localizable.xcstrings | 4 +- myApp/HaruDanim/IOS/Views/GoalViews.swift | 198 +++++++++++++++--- myApp/HaruDanim/IOS/Views/MainView.swift | 5 +- myApp/HaruDanim/IOS/Views/SettingsView.swift | 2 +- 7 files changed, 207 insertions(+), 33 deletions(-) diff --git a/myApp/HaruDanim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/HaruDanim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate index ce9c92491f70017e33766bfb22bbc69b741c6e64..f3269335a7f4971675de27a99d5c137bf3f8bd36 100644 GIT binary patch delta 111 zcmdn^igDvB#tp?HtfHI~%u+X3h)m-HF})l&3o9?z0Wr0tH}Cg9z$K!_(a$l7V=~7S zj;Rc44g8Hv6%G8GCr5d6sET|${A)oYQ*6F~07D2vK0^vaB10vE0+1{Lk|qpR42BGb Jo0*gKgaE&CAISg! delta 111 zcmdn^igDvB#tp?HtU~XER1G&*h)m-HF?E-27FJ%a17b>-ZQk#HfJ;P$qn~3E$7GHv z98($88u%NTDjIkP diff --git a/myApp/HaruDanim/IOS/Core/DebugSeed.swift b/myApp/HaruDanim/IOS/Core/DebugSeed.swift index b3b5dec..adf9b90 100644 --- a/myApp/HaruDanim/IOS/Core/DebugSeed.swift +++ b/myApp/HaruDanim/IOS/Core/DebugSeed.swift @@ -193,6 +193,33 @@ enum DebugSeed { weeklyRunQuest.targetSeconds = 2 * 3600 weeklyRunQuest.direction = .atLeast context.insert(weeklyRunQuest) + + // 목표 탭 정렬 순서 부여 + for (index, goal) in [toeic, health, habit, run].enumerated() { + goal.sortOrder = index + } + + // 완료된 목표 (완료 목표 분리 화면 검증용 — 달성 1개 + 미달성 1개) + let sleep = Goal( + title: "일찍 자기 챌린지", + symbolName: "moon.zzz.fill", + colorHex: "#2F6B4F", + startDate: cal.date(byAdding: .day, value: -60, to: now)!, + endDate: cal.date(byAdding: .day, value: -30, to: now)! + ) + sleep.status = .achieved + sleep.sortOrder = 4 + context.insert(sleep) + let diet = Goal( + title: "한 달 다이어트", + symbolName: "fork.knife", + colorHex: "#9D5B4A", + startDate: cal.date(byAdding: .day, value: -50, to: now)!, + endDate: cal.date(byAdding: .day, value: -20, to: now)! + ) + diet.status = .notAchieved + diet.sortOrder = 5 + context.insert(diet) } } #endif diff --git a/myApp/HaruDanim/IOS/Core/Models.swift b/myApp/HaruDanim/IOS/Core/Models.swift index bd9ed60..0688831 100644 --- a/myApp/HaruDanim/IOS/Core/Models.swift +++ b/myApp/HaruDanim/IOS/Core/Models.swift @@ -188,8 +188,10 @@ final class Goal { var statusRaw: String = GoalStatus.inProgress.rawValue /// 목표 탭에서 하위 다짐 목록 접힘 여부 var isCollapsed: Bool = false - /// 모음 탭 상단에 진행 현황을 표시할 목표 (설정에서 선택, 최대 1개) + /// 모음 탭 상단에 진행 현황을 표시할 목표 (설정에서 다중 선택) var showsOnMain: Bool = false + /// 목표 탭 리스트 정렬 순서 (드래그로 변경, 값이 같으면 createdAt 순) + var sortOrder: Int = 0 var createdAt: Date = Date() @Relationship(deleteRule: .cascade, inverse: \Quest.goal) diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index 58a34f6..dd96b52 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -270,7 +270,7 @@ "모음" : { }, - "모음 탭 목표 진행 현황 (최대 %lld개)" : { + "모음 탭 목표 진행 현황" : { }, "목표" : { @@ -376,7 +376,7 @@ "선택" : { }, - "선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 옆으로 넘겨보는 요약 카드(전체 다짐 평균)로 표시되고, 1개만 선택하면 표시 방식(다짐별로 각각 / 전체 다짐 합산)을 고를 수 있어요." : { + "선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 카드를 옆으로 쓸어 넘겨 한 장씩 볼 수 있고, 표시 방식(다짐별로 각각 / 전체 다짐 합산)은 모든 카드에 똑같이 적용돼요." : { }, "선택한 탭(최대 %lld개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요." : { diff --git a/myApp/HaruDanim/IOS/Views/GoalViews.swift b/myApp/HaruDanim/IOS/Views/GoalViews.swift index 21a3827..cc7a143 100644 --- a/myApp/HaruDanim/IOS/Views/GoalViews.swift +++ b/myApp/HaruDanim/IOS/Views/GoalViews.swift @@ -12,56 +12,125 @@ import SwiftData struct GoalListView: View { @Environment(\.modelContext) private var context - @Query(sort: \Goal.createdAt) private var goals: [Goal] + @Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal] @AppStorage(SettingsKeys.isPremium) private var isPremium = false @State private var showingAdd = false @State private var showLimitAlert = false + @State private var isReordering = false + @State private var editMode: EditMode = .inactive + + /// 진행 중(확인 필요 포함) 목표만 기본 목록에 표시 + 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 { - ForEach(goals) { goal in + if isReordering { Section { - NavigationLink { - GoalDetailView(goal: goal) - } label: { - GoalRow(goal: goal) + ForEach(activeGoals) { goal in + reorderRow(goal) } - if !goal.isCollapsed { - ForEach(goal.sortedQuests) { quest in - QuestRow(quest: quest) + .onMove(perform: moveGoals) + } footer: { + Text("오른쪽 핸들을 끌어서 순서를 바꾼 뒤 ‘완료’를 누르세요. 순서는 저장되어 유지돼요.") + } + } else { + ForEach(activeGoals) { goal in + Section { + NavigationLink { + GoalDetailView(goal: goal) + } label: { + GoalRow(goal: goal) + } + if !goal.isCollapsed { + ForEach(goal.sortedQuests) { quest in + QuestRow(quest: quest) + } + } + } header: { + if !goal.quests.isEmpty { + collapseHeader(goal) } } - } 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") } } } - if goals.isEmpty { - ContentUnavailableView( - "목표가 없어요", - systemImage: "flag.checkered", - description: Text("큰 목표를 세우고, 그 안에 다짐을 추가해 보세요.") - ) - } } .scrollContentBackground(.hidden) .background(AppTheme.background) .navigationTitle("목표") .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button { - if !isPremium && goals.count >= FreeLimits.goals { - showLimitAlert = true - } else { - showingAdd = true + if activeGoals.count > 1 { + Button(isReordering ? "완료" : "순서") { + withAnimation { + isReordering.toggle() + editMode = isReordering ? .active : .inactive + } + } + } + } + ToolbarItem(placement: .topBarTrailing) { + if !isReordering { + Button { + if !isPremium && goals.count >= FreeLimits.goals { + showLimitAlert = true + } else { + showingAdd = true + } + } label: { + Image(systemName: "plus") } - } label: { - Image(systemName: "plus") } } } + .environment(\.editMode, $editMode) .sheet(isPresented: $showingAdd) { GoalEditorView(goal: nil) } @@ -75,6 +144,40 @@ struct GoalListView: View { for goal in goals { goal.evaluateIfEnded(math: math) } + #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 } } @@ -227,6 +330,41 @@ struct QuestRow: View { } } +// MARK: - 완료된 목표 (달성/미달성으로 끝난 목표만 모아서 표시) + +struct FinishedGoalListView: View { + @Query( + filter: #Predicate { $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 { @@ -467,13 +605,17 @@ struct GoalEditorView: View { goal.startDate = startDate goal.endDate = hasEndDate ? endDate : nil } else { - context.insert(Goal( + let newGoal = Goal( title: trimmed, symbolName: symbolName, colorHex: color.hexString, startDate: startDate, endDate: hasEndDate ? endDate : nil - )) + ) + // 새 목표는 리스트 맨 뒤에 배치 + let existing = (try? context.fetch(FetchDescriptor())) ?? [] + newGoal.sortOrder = (existing.map(\.sortOrder).max() ?? -1) + 1 + context.insert(newGoal) } dismiss() } diff --git a/myApp/HaruDanim/IOS/Views/MainView.swift b/myApp/HaruDanim/IOS/Views/MainView.swift index 8ff1be1..ed07551 100644 --- a/myApp/HaruDanim/IOS/Views/MainView.swift +++ b/myApp/HaruDanim/IOS/Views/MainView.swift @@ -14,7 +14,10 @@ struct MainView: View { @Query(sort: \Action.sortOrder) private var actions: [Action] @Query(filter: #Predicate { $0.endAt == nil }, sort: \TimeSession.startAt) private var runningSessions: [TimeSession] - @Query(filter: #Predicate { $0.showsOnMain }, sort: \Goal.createdAt) private var pinnedGoals: [Goal] + @Query( + filter: #Predicate { $0.showsOnMain }, + sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)] + ) private var pinnedGoals: [Goal] @Environment(AppRouter.self) private var router diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index 0720105..9dc0c9e 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -9,7 +9,7 @@ import SwiftUI import SwiftData struct SettingsView: View { - @Query(sort: \Goal.createdAt) private var goals: [Goal] + @Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal] @AppStorage(SettingsKeys.theme) private var theme = "light" @AppStorage(SettingsKeys.language) private var language = AppLanguage.ko.rawValue @AppStorage(SettingsKeys.weekStartWeekday) private var weekStartWeekday = 2