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
This commit is contained in:
songyc macbook 2026-07-10 04:41:11 +09:00
parent ecc4c016f7
commit 0509285a04
7 changed files with 207 additions and 33 deletions

View File

@ -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

View File

@ -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)

View File

@ -270,7 +270,7 @@
"모음" : {
},
"모음 탭 목표 진행 현황 (최대 %lld개)" : {
"모음 탭 목표 진행 현황" : {
},
"목표" : {
@ -376,7 +376,7 @@
"선택" : {
},
"선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 옆으로 넘겨보는 요약 카드(전체 다짐 평균)로 표시되고, 1개만 선택하면 표시 방식(다짐별로 각각 / 전체 다짐 합산)을 고를 수 있어요." : {
"선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 카드를 옆으로 쓸어 넘겨 한 장씩 볼 수 있고, 표시 방식(다짐별로 각각 / 전체 다짐 합산)은 모든 카드에 똑같이 적용돼요." : {
},
"선택한 탭(최대 %lld개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요." : {

View File

@ -12,15 +12,51 @@ 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 {
ForEach(activeGoals) { goal in
reorderRow(goal)
}
.onMove(perform: moveGoals)
} footer: {
Text("오른쪽 핸들을 끌어서 순서를 바꾼 뒤 ‘완료’를 누르세요. 순서는 저장되어 유지돼요.")
}
} else {
ForEach(activeGoals) { goal in
Section {
NavigationLink {
GoalDetailView(goal: goal)
@ -38,19 +74,50 @@ struct GoalListView: View {
}
}
}
if goals.isEmpty {
if activeGoals.isEmpty {
ContentUnavailableView(
"목표가 없어요",
"진행 중인 목표가 없어요",
systemImage: "flag.checkered",
description: Text("큰 목표를 세우고, 그 안에 다짐을 추가해 보세요.")
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 ? "완료" : "순서") {
withAnimation {
isReordering.toggle()
editMode = isReordering ? .active : .inactive
}
}
}
}
ToolbarItem(placement: .topBarTrailing) {
if !isReordering {
Button {
if !isPremium && goals.count >= FreeLimits.goals {
showLimitAlert = true
@ -62,6 +129,8 @@ struct GoalListView: View {
}
}
}
}
.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<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 {
@ -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<Goal>())) ?? []
newGoal.sortOrder = (existing.map(\.sortOrder).max() ?? -1) + 1
context.insert(newGoal)
}
dismiss()
}

View File

@ -14,7 +14,10 @@ struct MainView: View {
@Query(sort: \Action.sortOrder) private var actions: [Action]
@Query(filter: #Predicate<TimeSession> { $0.endAt == nil }, sort: \TimeSession.startAt)
private var runningSessions: [TimeSession]
@Query(filter: #Predicate<Goal> { $0.showsOnMain }, sort: \Goal.createdAt) private var pinnedGoals: [Goal]
@Query(
filter: #Predicate<Goal> { $0.showsOnMain },
sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]
) private var pinnedGoals: [Goal]
@Environment(AppRouter.self) private var router

View File

@ -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