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
|
||||
DebugSeed.seedIfRequested(context: context)
|
||||
DebugSeed.autoStartIfRequested(context: context)
|
||||
DebugSeed.pinGoalsIfRequested(context: context)
|
||||
#endif
|
||||
try? await Task.sleep(for: .seconds(1.2))
|
||||
withAnimation(.easeOut(duration: 0.4)) {
|
||||
|
||||
@ -21,6 +21,17 @@ enum DebugSeed {
|
||||
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) {
|
||||
guard UserDefaults.standard.bool(forKey: "seedDemo") else { return }
|
||||
let existing = (try? context.fetchCount(FetchDescriptor<Action>())) ?? 0
|
||||
@ -148,6 +159,23 @@ enum DebugSeed {
|
||||
videoQuest.targetSeconds = 3600
|
||||
videoQuest.direction = .atMost
|
||||
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
|
||||
|
||||
@ -30,6 +30,12 @@ enum SettingsKeys {
|
||||
static let goalCardStyle = "settings.goalCardStyle"
|
||||
}
|
||||
|
||||
/// 모음 탭 상단 목표 카드 구성
|
||||
enum MainGoalCard {
|
||||
/// 동시에 표시할 수 있는 목표 최대 개수
|
||||
static let maxPinned = 3
|
||||
}
|
||||
|
||||
/// 모음 탭 목표 진행 현황 카드의 표시 방식
|
||||
enum GoalCardStyle: String, CaseIterable, Identifiable {
|
||||
/// 다짐(최대 3개)별로 하루/주간/월간 진행률을 각각 표시
|
||||
|
||||
@ -14,7 +14,7 @@ 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 }) private var pinnedGoals: [Goal]
|
||||
@Query(filter: #Predicate<Goal> { $0.showsOnMain }, sort: \Goal.createdAt) private var pinnedGoals: [Goal]
|
||||
|
||||
@Environment(AppRouter.self) private var router
|
||||
|
||||
@ -41,8 +41,8 @@ struct MainView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let goal = pinnedGoals.first, !isEditing {
|
||||
GoalSummaryCard(goal: goal)
|
||||
if !pinnedGoals.isEmpty && !isEditing {
|
||||
goalArea
|
||||
}
|
||||
if anyRunning && !isEditing {
|
||||
runningArea
|
||||
@ -118,6 +118,30 @@ struct MainView: View {
|
||||
#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: 현재 진행 중 영역
|
||||
|
||||
private var runningArea: some View {
|
||||
@ -308,6 +332,8 @@ struct MainView: View {
|
||||
|
||||
struct GoalSummaryCard: View {
|
||||
let goal: Goal
|
||||
/// 여러 목표를 가로 슬라이드로 보여줄 때의 축약 모드 (전체 다짐 평균 한 줄만 표시)
|
||||
var compact: Bool = false
|
||||
|
||||
@AppStorage(SettingsKeys.goalCardStyle) private var cardStyleRaw = GoalCardStyle.perQuest.rawValue
|
||||
|
||||
@ -350,6 +376,9 @@ struct GoalSummaryCard: View {
|
||||
}
|
||||
}
|
||||
if !goal.sortedQuests.isEmpty {
|
||||
if compact {
|
||||
combinedBlock
|
||||
} else {
|
||||
switch cardStyle {
|
||||
case .perQuest:
|
||||
ForEach(goal.sortedQuests.prefix(3)) { quest in
|
||||
@ -360,8 +389,9 @@ struct GoalSummaryCard: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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))
|
||||
.contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
}
|
||||
|
||||
@ -36,18 +36,12 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 모음 탭에 표시할 목표 선택 ↔ Goal.showsOnMain 플래그 변환 (최대 1개 유지)
|
||||
private var pinnedGoalSelection: Binding<PersistentIdentifier?> {
|
||||
Binding {
|
||||
goals.first { $0.showsOnMain }?.persistentModelID
|
||||
} set: { newValue in
|
||||
for goal in goals {
|
||||
goal.showsOnMain = goal.persistentModelID == newValue
|
||||
}
|
||||
}
|
||||
private var pinnedGoals: [Goal] {
|
||||
goals.filter(\.showsOnMain)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
Form {
|
||||
Section("화면") {
|
||||
Picker("테마", selection: $theme) {
|
||||
@ -70,13 +64,16 @@ struct SettingsView: View {
|
||||
Text("선택한 탭(최대 \(TabBarConfig.maxVisible)개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요.")
|
||||
}
|
||||
Section {
|
||||
Picker("목표 진행 현황 표시", selection: pinnedGoalSelection) {
|
||||
Text("표시 안 함").tag(nil as PersistentIdentifier?)
|
||||
if goals.isEmpty {
|
||||
Text("목표 탭에서 목표를 만들면 여기서 선택할 수 있어요.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
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) {
|
||||
ForEach(GoalCardStyle.allCases) { style in
|
||||
Text(style.label).tag(style.rawValue)
|
||||
@ -84,10 +81,11 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("모음 탭")
|
||||
Text("모음 탭 목표 진행 현황 (최대 \(MainGoalCard.maxPinned)개)")
|
||||
} footer: {
|
||||
Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. ‘다짐별로 각각’은 다짐(최대 3개)마다 하루·주간·월간 진행률을, ‘전체 다짐 합산’은 소속 다짐 전체의 평균 진행률을 한 줄로 보여줘요.")
|
||||
Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 옆으로 넘겨보는 요약 카드(전체 다짐 평균)로 표시되고, 1개만 선택하면 표시 방식(다짐별로 각각 / 전체 다짐 합산)을 고를 수 있어요.")
|
||||
}
|
||||
.id("goalSection")
|
||||
Section {
|
||||
Picker("주 시작 요일", selection: $weekStartWeekday) {
|
||||
ForEach(1...7, id: \.self) { weekday in
|
||||
@ -147,6 +145,52 @@ struct SettingsView: View {
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppTheme.background)
|
||||
.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: 탭바 구성
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user