diff --git a/myApp/HaruDanim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/HaruDanim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate index 232515b..153e7ad 100644 Binary files a/myApp/HaruDanim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate and b/myApp/HaruDanim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index fa954a9..90345b2 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -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)) { diff --git a/myApp/HaruDanim/IOS/Core/DebugSeed.swift b/myApp/HaruDanim/IOS/Core/DebugSeed.swift index 127785f..e4174df 100644 --- a/myApp/HaruDanim/IOS/Core/DebugSeed.swift +++ b/myApp/HaruDanim/IOS/Core/DebugSeed.swift @@ -21,6 +21,17 @@ enum DebugSeed { LiveActivityManager.sync(context: context) } + /// `-pinGoals ` 런치 인자: 생성순으로 목표 N개를 모음 탭에 표시 (다중 목표 카드 검증용) + static func pinGoalsIfRequested(context: ModelContext) { + let n = UserDefaults.standard.integer(forKey: "pinGoals") + guard n > 0 else { return } + let descriptor = FetchDescriptor(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())) ?? 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 diff --git a/myApp/HaruDanim/IOS/Core/Settings.swift b/myApp/HaruDanim/IOS/Core/Settings.swift index 6afd6ff..6653d44 100644 --- a/myApp/HaruDanim/IOS/Core/Settings.swift +++ b/myApp/HaruDanim/IOS/Core/Settings.swift @@ -30,6 +30,12 @@ enum SettingsKeys { static let goalCardStyle = "settings.goalCardStyle" } +/// 모음 탭 상단 목표 카드 구성 +enum MainGoalCard { + /// 동시에 표시할 수 있는 목표 최대 개수 + static let maxPinned = 3 +} + /// 모음 탭 목표 진행 현황 카드의 표시 방식 enum GoalCardStyle: String, CaseIterable, Identifiable { /// 다짐(최대 3개)별로 하루/주간/월간 진행률을 각각 표시 diff --git a/myApp/HaruDanim/IOS/Views/MainView.swift b/myApp/HaruDanim/IOS/Views/MainView.swift index d731c4e..ee2583c 100644 --- a/myApp/HaruDanim/IOS/Views/MainView.swift +++ b/myApp/HaruDanim/IOS/Views/MainView.swift @@ -14,7 +14,7 @@ struct MainView: View { @Query(sort: \Action.sortOrder) private var actions: [Action] @Query(filter: #Predicate { $0.endAt == nil }, sort: \TimeSession.startAt) private var runningSessions: [TimeSession] - @Query(filter: #Predicate { $0.showsOnMain }) private var pinnedGoals: [Goal] + @Query(filter: #Predicate { $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,18 +376,22 @@ struct GoalSummaryCard: View { } } if !goal.sortedQuests.isEmpty { - switch cardStyle { - case .perQuest: - ForEach(goal.sortedQuests.prefix(3)) { quest in - questSpanBlock(quest) - } - case .combined: + if compact { combinedBlock + } else { + switch cardStyle { + case .perQuest: + ForEach(goal.sortedQuests.prefix(3)) { quest in + questSpanBlock(quest) + } + case .combined: + combinedBlock + } } } } .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)) } diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index 625c762..bceb8b7 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -36,18 +36,12 @@ struct SettingsView: View { } } - /// 모음 탭에 표시할 목표 선택 ↔ Goal.showsOnMain 플래그 변환 (최대 1개 유지) - private var pinnedGoalSelection: Binding { - 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: 탭바 구성