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:
parent
ecc4c016f7
commit
0509285a04
Binary file not shown.
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -270,7 +270,7 @@
|
||||
"모음" : {
|
||||
|
||||
},
|
||||
"모음 탭 목표 진행 현황 (최대 %lld개)" : {
|
||||
"모음 탭 목표 진행 현황" : {
|
||||
|
||||
},
|
||||
"목표" : {
|
||||
@ -376,7 +376,7 @@
|
||||
"선택" : {
|
||||
|
||||
},
|
||||
"선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 옆으로 넘겨보는 요약 카드(전체 다짐 평균)로 표시되고, 1개만 선택하면 표시 방식(다짐별로 각각 / 전체 다짐 합산)을 고를 수 있어요." : {
|
||||
"선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 카드를 옆으로 쓸어 넘겨 한 장씩 볼 수 있고, 표시 방식(다짐별로 각각 / 전체 다짐 합산)은 모든 카드에 똑같이 적용돼요." : {
|
||||
|
||||
},
|
||||
"선택한 탭(최대 %lld개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요." : {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user