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:
parent
ce5f239025
commit
352be3e29b
Binary file not shown.
@ -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)) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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개)별로 하루/주간/월간 진행률을 각각 표시
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: 탭바 구성
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user