feat(main): allow displaying up to 3 goals on the top progress widget

- Update the goal progress widget in the main tab to support up to 3 selected goals
- Add multi-selection capabilities to the widget settings menu
- Implement a horizontal or scrollable layout for multiple goals to optimize screen space
- Maintain existing routing to specific goal detail screens upon tap
This commit is contained in:
songyc macbook 2026-07-10 03:52:51 +09:00
parent ce5f239025
commit 352be3e29b
6 changed files with 134 additions and 25 deletions

View File

@ -28,6 +28,7 @@ struct ContentView: View {
#if DEBUG #if DEBUG
DebugSeed.seedIfRequested(context: context) DebugSeed.seedIfRequested(context: context)
DebugSeed.autoStartIfRequested(context: context) DebugSeed.autoStartIfRequested(context: context)
DebugSeed.pinGoalsIfRequested(context: context)
#endif #endif
try? await Task.sleep(for: .seconds(1.2)) try? await Task.sleep(for: .seconds(1.2))
withAnimation(.easeOut(duration: 0.4)) { withAnimation(.easeOut(duration: 0.4)) {

View File

@ -21,6 +21,17 @@ enum DebugSeed {
LiveActivityManager.sync(context: context) LiveActivityManager.sync(context: context)
} }
/// `-pinGoals <N>` : N ( )
static func pinGoalsIfRequested(context: ModelContext) {
let n = UserDefaults.standard.integer(forKey: "pinGoals")
guard n > 0 else { return }
let descriptor = FetchDescriptor<Goal>(sortBy: [SortDescriptor(\.createdAt)])
guard let goals = try? context.fetch(descriptor) else { return }
for (index, goal) in goals.enumerated() {
goal.showsOnMain = index < n
}
}
static func seedIfRequested(context: ModelContext) { static func seedIfRequested(context: ModelContext) {
guard UserDefaults.standard.bool(forKey: "seedDemo") else { return } guard UserDefaults.standard.bool(forKey: "seedDemo") else { return }
let existing = (try? context.fetchCount(FetchDescriptor<Action>())) ?? 0 let existing = (try? context.fetchCount(FetchDescriptor<Action>())) ?? 0
@ -148,6 +159,23 @@ enum DebugSeed {
videoQuest.targetSeconds = 3600 videoQuest.targetSeconds = 3600
videoQuest.direction = .atMost videoQuest.direction = .atMost
context.insert(videoQuest) context.insert(videoQuest)
// ( )
let habit = Goal(
title: "매일 밤 독서 습관",
symbolName: "book.fill",
colorHex: "#9D5B4A",
startDate: cal.date(byAdding: .day, value: -5, to: now)!,
endDate: cal.date(byAdding: .day, value: 60, to: now)!
)
context.insert(habit)
let readingQuest = Quest(goal: habit)
readingQuest.targetAction = reading
readingQuest.measure = .time
readingQuest.period = .daily
readingQuest.targetSeconds = 45 * 60
readingQuest.direction = .atLeast
context.insert(readingQuest)
} }
} }
#endif #endif

View File

@ -30,6 +30,12 @@ enum SettingsKeys {
static let goalCardStyle = "settings.goalCardStyle" static let goalCardStyle = "settings.goalCardStyle"
} }
///
enum MainGoalCard {
///
static let maxPinned = 3
}
/// ///
enum GoalCardStyle: String, CaseIterable, Identifiable { enum GoalCardStyle: String, CaseIterable, Identifiable {
/// ( 3) // /// ( 3) //

View File

@ -14,7 +14,7 @@ struct MainView: View {
@Query(sort: \Action.sortOrder) private var actions: [Action] @Query(sort: \Action.sortOrder) private var actions: [Action]
@Query(filter: #Predicate<TimeSession> { $0.endAt == nil }, sort: \TimeSession.startAt) @Query(filter: #Predicate<TimeSession> { $0.endAt == nil }, sort: \TimeSession.startAt)
private var runningSessions: [TimeSession] private var runningSessions: [TimeSession]
@Query(filter: #Predicate<Goal> { $0.showsOnMain }) private var pinnedGoals: [Goal] @Query(filter: #Predicate<Goal> { $0.showsOnMain }, sort: \Goal.createdAt) private var pinnedGoals: [Goal]
@Environment(AppRouter.self) private var router @Environment(AppRouter.self) private var router
@ -41,8 +41,8 @@ struct MainView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
if let goal = pinnedGoals.first, !isEditing { if !pinnedGoals.isEmpty && !isEditing {
GoalSummaryCard(goal: goal) goalArea
} }
if anyRunning && !isEditing { if anyRunning && !isEditing {
runningArea runningArea
@ -118,6 +118,30 @@ struct MainView: View {
#endif #endif
} }
// MARK: (1= , 2 = )
@ViewBuilder
private var goalArea: some View {
if pinnedGoals.count == 1, let goal = pinnedGoals.first {
GoalSummaryCard(goal: goal)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(pinnedGoals.prefix(MainGoalCard.maxPinned)) { goal in
GoalSummaryCard(goal: goal, compact: true)
// , ( )
.containerRelativeFrame(.horizontal) { length, _ in
min(length * 0.72, 300)
}
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.scrollClipDisabled()
}
}
// MARK: // MARK:
private var runningArea: some View { private var runningArea: some View {
@ -308,6 +332,8 @@ struct MainView: View {
struct GoalSummaryCard: View { struct GoalSummaryCard: View {
let goal: Goal let goal: Goal
/// ( )
var compact: Bool = false
@AppStorage(SettingsKeys.goalCardStyle) private var cardStyleRaw = GoalCardStyle.perQuest.rawValue @AppStorage(SettingsKeys.goalCardStyle) private var cardStyleRaw = GoalCardStyle.perQuest.rawValue
@ -350,18 +376,22 @@ struct GoalSummaryCard: View {
} }
} }
if !goal.sortedQuests.isEmpty { if !goal.sortedQuests.isEmpty {
switch cardStyle { if compact {
case .perQuest:
ForEach(goal.sortedQuests.prefix(3)) { quest in
questSpanBlock(quest)
}
case .combined:
combinedBlock combinedBlock
} else {
switch cardStyle {
case .perQuest:
ForEach(goal.sortedQuests.prefix(3)) { quest in
questSpanBlock(quest)
}
case .combined:
combinedBlock
}
} }
} }
} }
.padding(14) .padding(14)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
} }

View File

@ -36,18 +36,12 @@ struct SettingsView: View {
} }
} }
/// Goal.showsOnMain ( 1 ) private var pinnedGoals: [Goal] {
private var pinnedGoalSelection: Binding<PersistentIdentifier?> { goals.filter(\.showsOnMain)
Binding {
goals.first { $0.showsOnMain }?.persistentModelID
} set: { newValue in
for goal in goals {
goal.showsOnMain = goal.persistentModelID == newValue
}
}
} }
var body: some View { var body: some View {
ScrollViewReader { proxy in
Form { Form {
Section("화면") { Section("화면") {
Picker("테마", selection: $theme) { Picker("테마", selection: $theme) {
@ -70,13 +64,16 @@ struct SettingsView: View {
Text("선택한 탭(최대 \(TabBarConfig.maxVisible)개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요.") Text("선택한 탭(최대 \(TabBarConfig.maxVisible)개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요.")
} }
Section { Section {
Picker("목표 진행 현황 표시", selection: pinnedGoalSelection) { if goals.isEmpty {
Text("표시 안 함").tag(nil as PersistentIdentifier?) Text("목표 탭에서 목표를 만들면 여기서 선택할 수 있어요.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(goals) { goal in ForEach(goals) { goal in
Text(goal.title).tag(Optional(goal.persistentModelID)) pinnedGoalRow(goal)
} }
} }
if goals.contains(where: \.showsOnMain) { if pinnedGoals.count == 1 {
Picker("표시 방식", selection: $goalCardStyle) { Picker("표시 방식", selection: $goalCardStyle) {
ForEach(GoalCardStyle.allCases) { style in ForEach(GoalCardStyle.allCases) { style in
Text(style.label).tag(style.rawValue) Text(style.label).tag(style.rawValue)
@ -84,10 +81,11 @@ struct SettingsView: View {
} }
} }
} header: { } header: {
Text("모음 탭") Text("모음 탭 목표 진행 현황 (최대 \(MainGoalCard.maxPinned)개)")
} footer: { } footer: {
Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. ‘다짐별로 각각’은 다짐(최대 3개)마다 하루·주간·월간 진행률을, ‘전체 다짐 합산’은 소속 다짐 전체의 평균 진행률을 한 줄로 보여줘요.") Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 옆으로 넘겨보는 요약 카드(전체 다짐 평균)로 표시되고, 1개만 선택하면 표시 방식(다짐별로 각각 / 전체 다짐 합산)을 고를 수 있어요.")
} }
.id("goalSection")
Section { Section {
Picker("주 시작 요일", selection: $weekStartWeekday) { Picker("주 시작 요일", selection: $weekStartWeekday) {
ForEach(1...7, id: \.self) { weekday in ForEach(1...7, id: \.self) { weekday in
@ -147,6 +145,52 @@ struct SettingsView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(AppTheme.background) .background(AppTheme.background)
.navigationTitle("설정") .navigationTitle("설정")
#if DEBUG
// : -settingsScrollGoal YES
.onAppear {
if UserDefaults.standard.bool(forKey: "settingsScrollGoal") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { proxy.scrollTo("goalSection", anchor: .top) }
}
}
}
#endif
}
}
// MARK: ( MainGoalCard.maxPinned )
private func pinnedGoalRow(_ goal: Goal) -> some View {
let isPinned = goal.showsOnMain
let atMax = pinnedGoals.count >= MainGoalCard.maxPinned
return Button {
if isPinned {
goal.showsOnMain = false
} else if !atMax {
goal.showsOnMain = true
}
} label: {
HStack {
Label {
Text(goal.title)
.foregroundStyle(.primary)
.lineLimit(1)
} icon: {
Image(systemName: goal.symbolName)
.foregroundStyle(goal.color)
}
Spacer()
if isPinned {
Image(systemName: "checkmark")
.foregroundStyle(AppTheme.green)
.fontWeight(.semibold)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!isPinned && atMax)
.opacity(!isPinned && atMax ? 0.5 : 1)
} }
// MARK: // MARK: