다짐 단위 '연속 달성' 표기 추가 (QuestProgress.streak → QuestStreakInfo): - 하루 단위 다짐: 적용일만 세는 연속 계산 — 매일이면 연속 일수, '매주 수요일'이면 수요일들만 이어서 센다 (4주 연속 = 연속 4일) - 주간/월간 다짐: 주기 전체 달성 여부로 연속 N주/N달 - 특정 기간 다짐: 반복이 없어 표시하지 않음 - 진행 중인 오늘/이번 주기는 '이상 달성'이면 미달이어도 끊기지 않고 건너뛰며, 채웠으면 포함. '이하 유지'는 한도를 넘는 순간 끊김 - 성능: 기록을 논리적 하루 키로 1회 버킷팅해 O(기록수+일수), 소급 상한 400일 표기(기존 디자인·데이터는 그대로 두고 추가만, 0이면 숨김): - 목표 탭 다짐 행: 이름 옆 불꽃 + "연속 N일" 배지 (노랑) - 위젯 ③ 다짐 진행률: 퍼센트 아래 한 줄 - 위젯 ④ 다짐 현황: 이름 아래 작은 글씨 - 위젯 ② 목표+다짐 구성: 퍼센트 아래 6.5pt — 연속 표기가 있을 때만 행 높이 14→19 (적응 채움이 자동 반영) - 문구는 '스트릭' 대신 "연속 N일/주/달" (en은 streak, ja는 N日連続) 문구 정정: '몇째 주 요일' → '몇 번째 요일' — 구현(weekdayOrdinal)은 달력의 주차가 아니라 "그 달의 n번째 ◯요일"이므로 편집 화면 픽커와 주기 요약 문구를 실제 동작에 맞게 수정 (동작 변경 없음). en/ja 번역 포함. 검증: 시드 데이터로 목표 탭 배지 손계산 대조(독서 연속 3일 — 3일 전 기록 없음 / 영상 시청 어제 한도 초과로 연속 1일 등 전부 일치), 위젯 ②③④ 미리보기 스크린샷으로 레이아웃 넘침 없음 확인, missing: [], Debug·Store 스킴 빌드 성공. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
327 lines
12 KiB
Swift
327 lines
12 KiB
Swift
//
|
|
// QuestEditorView.swift
|
|
// Haru_Danim
|
|
//
|
|
// 다짐 추가/수정: ①대상(행동/꼬리표) ②주기 ③목표량+방향 (CLAUDE.md §6.4)
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
struct QuestEditorView: View {
|
|
@Environment(\.modelContext) private var context
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Query(sort: \Action.createdAt) private var allActionsQuery: [Action]
|
|
@Query(sort: \Tag.createdAt) private var allTags: [Tag]
|
|
|
|
// 표시 순서는 모음 탭 배치와 같은 기기별 로컬 설정을 따른다 (LocalPrefs 참고)
|
|
private var allActions: [Action] {
|
|
LocalPrefs.orderedActions(allActionsQuery)
|
|
}
|
|
|
|
let goal: Goal
|
|
/// nil이면 새 다짐
|
|
let quest: Quest?
|
|
|
|
enum TargetKind: String, CaseIterable, Identifiable {
|
|
case action, tag
|
|
var id: String { rawValue }
|
|
var label: String { self == .action ? String(localized: "행동") : String(localized: "꼬리표") }
|
|
}
|
|
|
|
// ① 대상
|
|
@State private var targetKind: TargetKind = .action
|
|
@State private var selectedAction: Action?
|
|
@State private var selectedTag: Tag?
|
|
@State private var measure: TrackingType = .time
|
|
|
|
// ② 주기
|
|
@State private var period: QuestPeriod = .daily
|
|
@State private var scheduleMode: QuestScheduleMode = .everyDay
|
|
@State private var weekdays: Set<Int> = []
|
|
@State private var monthDays: Set<Int> = []
|
|
@State private var ordinalWeek = 1
|
|
@State private var ordinalWeekday = 2
|
|
@State private var customStart: Date = .now
|
|
@State private var customEnd: Date = .now
|
|
|
|
// ③ 목표량 + 방향
|
|
@State private var targetHours = 1
|
|
@State private var targetMinutes = 0
|
|
@State private var targetCount = 1
|
|
@State private var direction: QuestDirection = .atLeast
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
targetSection
|
|
periodSection
|
|
amountSection
|
|
}
|
|
.navigationTitle(quest == nil ? String(localized: "다짐 추가") : String(localized: "다짐 수정"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("취소") { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("저장") { save() }
|
|
.disabled(!isValid)
|
|
}
|
|
}
|
|
.onAppear(perform: load)
|
|
}
|
|
}
|
|
|
|
// MARK: ① 대상 선택
|
|
|
|
private var targetSection: some View {
|
|
Section("① 대상 선택") {
|
|
Picker("대상 종류", selection: $targetKind.animation()) {
|
|
ForEach(TargetKind.allCases) { kind in
|
|
Text(kind.label).tag(kind)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
|
|
switch targetKind {
|
|
case .action:
|
|
if allActions.isEmpty {
|
|
Text("등록된 행동이 없어요").foregroundStyle(.secondary)
|
|
} else {
|
|
Picker("행동", selection: $selectedAction) {
|
|
Text("선택").tag(Action?.none)
|
|
ForEach(allActions) { action in
|
|
Label(action.name, systemImage: action.symbolName)
|
|
.tag(Optional(action))
|
|
}
|
|
}
|
|
}
|
|
case .tag:
|
|
if allTags.isEmpty {
|
|
Text("등록된 꼬리표가 없어요").foregroundStyle(.secondary)
|
|
} else {
|
|
Picker("꼬리표", selection: $selectedTag) {
|
|
Text("선택").tag(Tag?.none)
|
|
ForEach(allTags) { tag in
|
|
Text(tag.name).tag(Optional(tag))
|
|
}
|
|
}
|
|
Picker("측정 기준", selection: $measure) {
|
|
ForEach(TrackingType.allCases) { type in
|
|
Text(type.label).tag(type)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: ② 주기 선택
|
|
|
|
@ViewBuilder
|
|
private var periodSection: some View {
|
|
Section("② 주기 선택") {
|
|
Picker("반복 주기", selection: $period.animation()) {
|
|
ForEach(QuestPeriod.allCases) { p in
|
|
Text(p.label).tag(p)
|
|
}
|
|
}
|
|
|
|
if period == .daily {
|
|
Picker("적용일", selection: $scheduleMode.animation()) {
|
|
ForEach(QuestScheduleMode.allCases) { mode in
|
|
Text(mode.label).tag(mode)
|
|
}
|
|
}
|
|
switch scheduleMode {
|
|
case .everyDay:
|
|
EmptyView()
|
|
case .weekdays:
|
|
weekdayPicker
|
|
case .monthDays:
|
|
monthDayPicker
|
|
case .ordinalWeekday:
|
|
// "n번째 ◯요일" (weekdayOrdinal) — 달력의 'n째 주'와 다를 수 있어 문구를 맞춘다
|
|
Picker("몇 번째", selection: $ordinalWeek) {
|
|
ForEach(1...5, id: \.self) { n in
|
|
Text("\(n)번째").tag(n)
|
|
}
|
|
}
|
|
Picker("요일", selection: $ordinalWeekday) {
|
|
ForEach(1...7, id: \.self) { w in
|
|
Text("\(Format.weekdayShort(w))요일").tag(w)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if period == .custom {
|
|
DatePicker("시작", selection: $customStart, displayedComponents: .date)
|
|
DatePicker("끝", selection: $customEnd, in: customStart..., displayedComponents: .date)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var weekdayPicker: some View {
|
|
HStack(spacing: 6) {
|
|
ForEach(1...7, id: \.self) { weekday in
|
|
let on = weekdays.contains(weekday)
|
|
Button {
|
|
if on { weekdays.remove(weekday) } else { weekdays.insert(weekday) }
|
|
} label: {
|
|
Text(Format.weekdayShort(weekday))
|
|
.font(.subheadline.weight(.semibold))
|
|
.frame(width: 36, height: 36)
|
|
.background(on ? AppTheme.green : AppTheme.surface, in: Circle())
|
|
.foregroundStyle(on ? Color.white : Color.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private var monthDayPicker: some View {
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 7), spacing: 6) {
|
|
ForEach(1...31, id: \.self) { day in
|
|
let on = monthDays.contains(day)
|
|
Button {
|
|
if on { monthDays.remove(day) } else { monthDays.insert(day) }
|
|
} label: {
|
|
Text("\(day)")
|
|
.font(.footnote.weight(.medium))
|
|
.frame(width: 34, height: 34)
|
|
.background(on ? AppTheme.green : AppTheme.surface, in: Circle())
|
|
.foregroundStyle(on ? Color.white : Color.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
// MARK: ③ 목표량 + 방향
|
|
|
|
private var effectiveMeasure: TrackingType {
|
|
targetKind == .action ? (selectedAction?.trackingType ?? .time) : measure
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var amountSection: some View {
|
|
Section {
|
|
if effectiveMeasure == .time {
|
|
HStack {
|
|
Picker("시간", selection: $targetHours) {
|
|
ForEach(0...23, id: \.self) { h in
|
|
Text("\(h)시간").tag(h)
|
|
}
|
|
}
|
|
.pickerStyle(.wheel)
|
|
.frame(maxWidth: .infinity)
|
|
Picker("분", selection: $targetMinutes) {
|
|
ForEach(Array(stride(from: 0, through: 55, by: 5)), id: \.self) { m in
|
|
Text("\(m)분").tag(m)
|
|
}
|
|
}
|
|
.pickerStyle(.wheel)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.frame(height: 110)
|
|
} else {
|
|
Stepper(value: $targetCount, in: 1...9999) {
|
|
HStack {
|
|
Text("목표 횟수")
|
|
Spacer()
|
|
Text("\(targetCount)회").foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
Picker("방향", selection: $direction) {
|
|
ForEach(QuestDirection.allCases) { dir in
|
|
Text(dir.label).tag(dir)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
} header: {
|
|
Text("③ 목표량과 방향")
|
|
} footer: {
|
|
Text(direction == .atLeast
|
|
? "주기마다 목표량 이상을 달성하는 것이 목표예요."
|
|
: "주기마다 목표량을 넘지 않는 것이 목표예요.")
|
|
}
|
|
}
|
|
|
|
// MARK: 저장
|
|
|
|
private var isValid: Bool {
|
|
let hasTarget = targetKind == .action ? selectedAction != nil : selectedTag != nil
|
|
guard hasTarget else { return false }
|
|
if period == .daily {
|
|
switch scheduleMode {
|
|
case .weekdays: if weekdays.isEmpty { return false }
|
|
case .monthDays: if monthDays.isEmpty { return false }
|
|
default: break
|
|
}
|
|
}
|
|
if period == .custom && customEnd < customStart { return false }
|
|
if effectiveMeasure == .time && targetHours == 0 && targetMinutes == 0 { return false }
|
|
return true
|
|
}
|
|
|
|
private func load() {
|
|
guard let quest else { return }
|
|
if let action = quest.targetAction {
|
|
targetKind = .action
|
|
selectedAction = action
|
|
} else if let tag = quest.targetTag {
|
|
targetKind = .tag
|
|
selectedTag = tag
|
|
}
|
|
measure = quest.measure
|
|
period = quest.period
|
|
scheduleMode = quest.scheduleMode
|
|
weekdays = Set(quest.weekdays)
|
|
monthDays = Set(quest.monthDays)
|
|
ordinalWeek = quest.ordinalWeek
|
|
ordinalWeekday = quest.ordinalWeekday
|
|
customStart = quest.customStart ?? .now
|
|
customEnd = quest.customEnd ?? .now
|
|
targetHours = Int(quest.targetSeconds) / 3600
|
|
targetMinutes = (Int(quest.targetSeconds) % 3600) / 60
|
|
targetCount = quest.targetCount
|
|
direction = quest.direction
|
|
}
|
|
|
|
private func save() {
|
|
let target = quest ?? Quest(goal: goal)
|
|
switch targetKind {
|
|
case .action:
|
|
target.targetAction = selectedAction
|
|
target.targetTag = nil
|
|
target.measure = selectedAction?.trackingType ?? .time
|
|
case .tag:
|
|
target.targetTag = selectedTag
|
|
target.targetAction = nil
|
|
target.measure = measure
|
|
}
|
|
target.period = period
|
|
target.scheduleMode = period == .daily ? scheduleMode : .everyDay
|
|
target.weekdays = Array(weekdays).sorted()
|
|
target.monthDays = Array(monthDays).sorted()
|
|
target.ordinalWeek = ordinalWeek
|
|
target.ordinalWeekday = ordinalWeekday
|
|
target.customStart = period == .custom ? customStart : nil
|
|
target.customEnd = period == .custom ? customEnd : nil
|
|
target.targetSeconds = Double(targetHours * 3600 + targetMinutes * 60)
|
|
target.targetCount = targetCount
|
|
target.direction = direction
|
|
if quest == nil {
|
|
target.sortOrder = (goal.quests.map(\.sortOrder).max() ?? -1) + 1
|
|
context.insert(target)
|
|
}
|
|
DataChange.commit(context: context)
|
|
dismiss()
|
|
}
|
|
}
|