전체 코드 최종 점검(보고)에서 지시받은 A·B·D 항목 처리:
A. 행동 수정 시 추적 방식 변경 잠금
기존 행동을 수정할 때 추적 방식(시간↔횟수) 피커가 활성화되어
있어 도움말("추적 방식은 바꿀 수 없으니")과 어긋났고, 바꾸면
기존 기록이 화면·통계에서 보이지 않는 것처럼 되는 문제.
→ 수정 모드에서 피커 비활성 + "만들 때만 선택할 수 있어요"
푸터 안내(ko/en/ja). 생성 시에는 기존과 동일.
B. 주차 라벨 지역화 2건
- 통계 탭 월간 주차별 꺾은선 x축 "N주차"가 String(localized:)
없이 하드코딩 — en/ja에서 한국어 노출 (내보내기와 불일치)
- ⑤ 행동 통계 위젯 주 단위 라벨 "N주" — 같은 문제 + 앱과 표기
불일치 → 둘 다 기존 번역 키 "%lld주차"(Week %lld/第%lld週) 사용
D. 결제 화면 기능 목록에 '제어 센터에서 실행' 항목 추가(ko/en/ja,
도움말·CLAUDE.md와 일치) + Widgets/InfoPlist.xcstrings의
CFBundleName을 IOS와 동일하게 shouldTranslate=false 처리.
C(시리 문구 미번역 의심)는 오탐 확인 — AppShortcuts 카탈로그는
stringSet(발화 변형) 구조로 ko/en/ja 모두 translated 상태.
검증: Debug·Store 빌드 성공, 영어 실행 통계 월간 화면에서
"Week 1~5" 표시 확인, 결제 화면에 제어 센터 항목 표시 확인,
전 카탈로그 missing 0·stale 0. CLAUDE.md §6.2 갱신.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
523 lines
20 KiB
Swift
523 lines
20 KiB
Swift
//
|
||
// ActionViews.swift
|
||
// Haru_Danim
|
||
//
|
||
// 행동 탭: 꼬리표별 그룹 리스트 + 상세 + 추가/수정 (CLAUDE.md §6.2)
|
||
//
|
||
|
||
import SwiftUI
|
||
import SwiftData
|
||
|
||
// MARK: - 행동 리스트 (꼬리표별 그룹)
|
||
|
||
struct ActionListView: View {
|
||
@Environment(\.modelContext) private var context
|
||
@Query(sort: [SortDescriptor(\Tag.sortOrder), SortDescriptor(\Tag.createdAt)]) private var tags: [Tag]
|
||
@Query(sort: \Action.createdAt) private var allActions: [Action]
|
||
private let premium = PremiumManager.shared
|
||
|
||
@State private var showingAdd = false
|
||
@State private var showLimitAlert = false
|
||
@State private var showingPremiumSheet = false
|
||
@State private var showingTagOrder = false
|
||
|
||
// 행동 표시 순서는 모음 탭 배치와 같은 기기별 로컬 설정을 따른다 (LocalPrefs 참고)
|
||
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
|
||
|
||
private var actions: [Action] {
|
||
LocalPrefs.orderedActions(allActions, raw: actionOrderRaw)
|
||
}
|
||
|
||
private var favoriteActions: [Action] {
|
||
actions.filter(\.isFavorite)
|
||
}
|
||
|
||
private var untaggedActions: [Action] {
|
||
actions.filter { $0.tags.isEmpty && !$0.isFavorite }
|
||
}
|
||
|
||
var body: some View {
|
||
List {
|
||
if !favoriteActions.isEmpty {
|
||
Section {
|
||
ForEach(favoriteActions) { action in
|
||
actionLink(action)
|
||
}
|
||
} header: {
|
||
Label("즐겨찾기", systemImage: "star.fill")
|
||
.foregroundStyle(AppTheme.yellow)
|
||
}
|
||
}
|
||
ForEach(tags) { tag in
|
||
let group = actions.filter { $0.tags.contains(tag) && !$0.isFavorite }
|
||
if !group.isEmpty {
|
||
Section {
|
||
ForEach(group) { action in
|
||
actionLink(action)
|
||
}
|
||
} header: {
|
||
HStack(spacing: 6) {
|
||
Circle().fill(tag.color).frame(width: 10, height: 10)
|
||
Text(tag.name)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if !untaggedActions.isEmpty {
|
||
Section("꼬리표 없음") {
|
||
ForEach(untaggedActions) { action in
|
||
actionLink(action)
|
||
}
|
||
}
|
||
}
|
||
if actions.isEmpty {
|
||
ContentUnavailableView(
|
||
"등록된 행동이 없어요",
|
||
systemImage: "figure.walk",
|
||
description: Text("오른쪽 위 + 버튼으로 행동을 추가하세요.")
|
||
)
|
||
}
|
||
}
|
||
.scrollContentBackground(.hidden)
|
||
.background(AppTheme.background)
|
||
.navigationTitle("행동")
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Menu {
|
||
Button {
|
||
showingTagOrder = true
|
||
} label: {
|
||
Label("꼬리표 순서 변경", systemImage: "arrow.up.arrow.down")
|
||
}
|
||
} label: {
|
||
Image(systemName: "ellipsis.circle")
|
||
}
|
||
}
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button {
|
||
if !premium.canAddAction(currentCount: actions.count) {
|
||
showLimitAlert = true
|
||
} else {
|
||
showingAdd = true
|
||
}
|
||
} label: {
|
||
Image(systemName: "plus")
|
||
}
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingAdd) {
|
||
ActionEditorView(action: nil)
|
||
}
|
||
.sheet(isPresented: $showingTagOrder) {
|
||
TagOrderSheet()
|
||
}
|
||
.alert("무료 사용 한도", isPresented: $showLimitAlert) {
|
||
Button("프리미엄 알아보기") { showingPremiumSheet = true }
|
||
Button("확인", role: .cancel) {}
|
||
} message: {
|
||
Text("무료 버전에서는 행동을 최대 \(FreeLimits.actions)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
|
||
}
|
||
.sheet(isPresented: $showingPremiumSheet) {
|
||
PremiumSheetView()
|
||
}
|
||
}
|
||
|
||
private func actionLink(_ action: Action) -> some View {
|
||
NavigationLink {
|
||
ActionDetailView(action: action)
|
||
} label: {
|
||
ActionRow(action: action)
|
||
}
|
||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||
Button {
|
||
action.isFavorite.toggle()
|
||
DataChange.commit(context: context)
|
||
} label: {
|
||
Label(
|
||
action.isFavorite ? String(localized: "즐겨찾기 해제") : String(localized: "즐겨찾기"),
|
||
systemImage: action.isFavorite ? "star.slash" : "star.fill"
|
||
)
|
||
}
|
||
.tint(AppTheme.yellow)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ActionRow: View {
|
||
let action: Action
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: action.symbolName)
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 34, height: 34)
|
||
.background(action.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack(spacing: 4) {
|
||
Text(action.name)
|
||
.font(.body.weight(.medium))
|
||
if action.isFavorite {
|
||
Image(systemName: "star.fill")
|
||
.font(.caption2)
|
||
.foregroundStyle(AppTheme.yellow)
|
||
}
|
||
}
|
||
Text(action.trackingType.label)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 꼬리표 순서 변경 시트 (행동 탭 그룹 순서에 반영)
|
||
|
||
struct TagOrderSheet: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Environment(\.modelContext) private var context
|
||
@Query(sort: [SortDescriptor(\Tag.sortOrder), SortDescriptor(\Tag.createdAt)]) private var tags: [Tag]
|
||
|
||
/// 드래그로 순서가 바뀌었는지 — 시트가 닫힐 때 한 번만 커밋해
|
||
/// 드래그마다 위젯·워치 갱신이 연발되지 않게 한다
|
||
@State private var orderChanged = false
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List {
|
||
ForEach(tags) { tag in
|
||
HStack(spacing: 10) {
|
||
Circle().fill(tag.color).frame(width: 14, height: 14)
|
||
Text(tag.name)
|
||
}
|
||
}
|
||
.onMove { source, destination in
|
||
var ordered = tags
|
||
ordered.move(fromOffsets: source, toOffset: destination)
|
||
for (index, tag) in ordered.enumerated() {
|
||
tag.sortOrder = index
|
||
}
|
||
orderChanged = true
|
||
}
|
||
}
|
||
.environment(\.editMode, .constant(.active))
|
||
.navigationTitle("꼬리표 순서")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("완료") { dismiss() }
|
||
}
|
||
}
|
||
}
|
||
.presentationDetents([.medium, .large])
|
||
.onDisappear {
|
||
// 꼬리표 순서는 워치 앱 그룹핑 순서에도 쓰이므로 닫힐 때 커밋
|
||
// ('완료' 버튼과 스와이프 내리기 둘 다 이 경로를 지난다)
|
||
if orderChanged { DataChange.commit(context: context) }
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 행동 상세
|
||
|
||
struct ActionDetailView: View {
|
||
@Environment(\.modelContext) private var context
|
||
@Environment(\.dismiss) private var dismiss
|
||
let action: Action
|
||
|
||
@State private var showingEdit = false
|
||
@State private var showingDelete = false
|
||
|
||
var body: some View {
|
||
List {
|
||
Section("기본 정보") {
|
||
HStack {
|
||
Text("이름")
|
||
Spacer()
|
||
Text(action.name).foregroundStyle(.secondary)
|
||
}
|
||
// 리스트 스와이프 토글과 동일하게 변경 즉시 커밋 (저장 확정)
|
||
Toggle(isOn: Binding(
|
||
get: { action.isFavorite },
|
||
set: { action.isFavorite = $0; DataChange.commit(context: context) }
|
||
)) {
|
||
Label {
|
||
Text("즐겨찾기")
|
||
} icon: {
|
||
Image(systemName: "star.fill")
|
||
.foregroundStyle(AppTheme.yellow)
|
||
}
|
||
}
|
||
// 메모 창 표시 여부는 기기별 로컬 설정 (CloudKit 동기화 제외, LocalPrefs 참고)
|
||
Toggle(isOn: Binding(
|
||
get: { LocalPrefs.promptsForNote(action.uuid) },
|
||
set: { LocalPrefs.setPromptsForNote(action.uuid, enabled: $0) }
|
||
)) {
|
||
Label {
|
||
Text("기록 시 메모 창 표시")
|
||
} icon: {
|
||
Image(systemName: "note.text")
|
||
.foregroundStyle(AppTheme.green)
|
||
}
|
||
}
|
||
HStack {
|
||
Text("아이콘")
|
||
Spacer()
|
||
Image(systemName: action.symbolName)
|
||
.foregroundStyle(.white)
|
||
.frame(width: 30, height: 30)
|
||
.background(action.color, in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
HStack {
|
||
Text("추적 방식")
|
||
Spacer()
|
||
Label(action.trackingType.label, systemImage: action.trackingType.symbol)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
Section("꼬리표") {
|
||
if action.tags.isEmpty {
|
||
Text("지정된 꼬리표 없음").foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(action.sortedTags) { tag in
|
||
HStack(spacing: 8) {
|
||
Circle().fill(tag.color).frame(width: 10, height: 10)
|
||
Text(tag.name)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Section("누적 기록") {
|
||
statsRows
|
||
}
|
||
Section {
|
||
Button("행동 삭제", role: .destructive) {
|
||
showingDelete = true
|
||
}
|
||
}
|
||
}
|
||
.scrollContentBackground(.hidden)
|
||
.background(AppTheme.background)
|
||
.navigationTitle(action.name)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar(.hidden, for: .tabBar)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("수정") { showingEdit = true }
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingEdit) {
|
||
ActionEditorView(action: action)
|
||
}
|
||
.alert("행동 삭제", isPresented: $showingDelete) {
|
||
Button("삭제", role: .destructive) {
|
||
context.delete(action)
|
||
DataChange.commit(context: context)
|
||
dismiss()
|
||
}
|
||
Button("취소", role: .cancel) {}
|
||
} message: {
|
||
Text("‘\(action.name)’ 행동을 삭제할까요? 기록도 함께 삭제됩니다.")
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var statsRows: some View {
|
||
let agg = Aggregator()
|
||
let math = agg.math
|
||
let now = Date.now
|
||
switch action.trackingType {
|
||
case .time:
|
||
statRow(String(localized: "오늘"), Format.durationShort(agg.seconds(for: action, in: math.dayRange(containing: now))))
|
||
statRow(String(localized: "이번 주"), Format.durationShort(agg.seconds(for: action, in: math.weekRange(containing: now))))
|
||
statRow(String(localized: "이번 달"), Format.durationShort(agg.seconds(for: action, in: math.monthRange(containing: now))))
|
||
case .count:
|
||
statRow(String(localized: "오늘"), String(localized: "\(agg.count(for: action, in: math.dayRange(containing: now)))회"))
|
||
statRow(String(localized: "이번 주"), String(localized: "\(agg.count(for: action, in: math.weekRange(containing: now)))회"))
|
||
statRow(String(localized: "이번 달"), String(localized: "\(agg.count(for: action, in: math.monthRange(containing: now)))회"))
|
||
}
|
||
}
|
||
|
||
private func statRow(_ title: String, _ value: String) -> some View {
|
||
HStack {
|
||
Text(title)
|
||
Spacer()
|
||
Text(value).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 행동 추가/수정
|
||
|
||
struct ActionEditorView: View {
|
||
@Environment(\.modelContext) private var context
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Query(sort: \Tag.createdAt) private var allTags: [Tag]
|
||
@Query private var allActions: [Action]
|
||
|
||
/// nil이면 새 행동 추가
|
||
let action: Action?
|
||
|
||
@State private var name = ""
|
||
@State private var symbolName = "star.fill"
|
||
@State private var trackingType: TrackingType = .time
|
||
@State private var promptsForNote = false
|
||
@State private var selectedTags: Set<PersistentIdentifier> = []
|
||
@State private var showingSymbolPicker = false
|
||
@State private var showingNewTag = false
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section("이름") {
|
||
TextField("행동 이름 (예: 독서)", text: $name)
|
||
}
|
||
Section("아이콘") {
|
||
Button {
|
||
showingSymbolPicker = true
|
||
} label: {
|
||
HStack {
|
||
Image(systemName: symbolName)
|
||
.font(.system(size: 20))
|
||
.foregroundStyle(AppTheme.green)
|
||
.frame(width: 36, height: 36)
|
||
Text("아이콘 선택")
|
||
Spacer()
|
||
Image(systemName: "chevron.right")
|
||
.font(.caption)
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
Section {
|
||
ForEach(allTags) { tag in
|
||
Button {
|
||
toggleTag(tag)
|
||
} label: {
|
||
HStack(spacing: 8) {
|
||
Circle().fill(tag.color).frame(width: 12, height: 12)
|
||
Text(tag.name)
|
||
.foregroundStyle(.primary)
|
||
Spacer()
|
||
if selectedTags.contains(tag.persistentModelID) {
|
||
Image(systemName: "checkmark")
|
||
.foregroundStyle(AppTheme.green)
|
||
.fontWeight(.semibold)
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
}
|
||
Button {
|
||
showingNewTag = true
|
||
} label: {
|
||
Label("새 꼬리표 만들기", systemImage: "plus.circle")
|
||
.foregroundStyle(AppTheme.green)
|
||
}
|
||
} header: {
|
||
Text("꼬리표 (복수 선택 가능)")
|
||
} footer: {
|
||
Text("행동 버튼의 색은 지정한 꼬리표의 색을 따라요.")
|
||
}
|
||
Section {
|
||
Picker("추적 방식", selection: $trackingType) {
|
||
ForEach(TrackingType.allCases) { type in
|
||
Label(type.label, systemImage: type.symbol).tag(type)
|
||
}
|
||
}
|
||
.pickerStyle(.inline)
|
||
.labelsHidden()
|
||
// 기존 행동의 추적 방식을 바꾸면 이미 쌓인 기록이 화면·통계에서
|
||
// 보이지 않는 것처럼 되므로 생성 시에만 선택 가능 (도움말 안내와 일치)
|
||
.disabled(action != nil)
|
||
} header: {
|
||
Text("추적 방식")
|
||
} footer: {
|
||
if action != nil {
|
||
Text("추적 방식은 행동을 만들 때만 선택할 수 있어요.")
|
||
}
|
||
}
|
||
Section {
|
||
Toggle(isOn: $promptsForNote) {
|
||
Label {
|
||
Text("기록 시 메모 창 표시")
|
||
} icon: {
|
||
Image(systemName: "note.text")
|
||
.foregroundStyle(AppTheme.green)
|
||
}
|
||
}
|
||
} footer: {
|
||
Text(trackingType == .time
|
||
? String(localized: "켜면 시간 측정을 종료할 때 메모 창이 떠요. 건너뛸 수 있어요.")
|
||
: String(localized: "켜면 횟수를 추가할 때마다 메모 창이 떠요. 건너뛸 수 있어요."))
|
||
}
|
||
}
|
||
.navigationTitle(action == nil ? String(localized: "행동 추가") : String(localized: "행동 수정"))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("취소") { dismiss() }
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("저장") { save() }
|
||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingSymbolPicker) {
|
||
SymbolPickerView(selection: $symbolName)
|
||
}
|
||
.sheet(isPresented: $showingNewTag) {
|
||
TagEditorView(tag: nil)
|
||
}
|
||
.onAppear(perform: load)
|
||
}
|
||
}
|
||
|
||
private func load() {
|
||
guard let action else { return }
|
||
name = action.name
|
||
symbolName = action.symbolName
|
||
trackingType = action.trackingType
|
||
promptsForNote = LocalPrefs.promptsForNote(action.uuid)
|
||
selectedTags = Set(action.tags.map(\.persistentModelID))
|
||
}
|
||
|
||
private func toggleTag(_ tag: Tag) {
|
||
if selectedTags.contains(tag.persistentModelID) {
|
||
selectedTags.remove(tag.persistentModelID)
|
||
} else {
|
||
selectedTags.insert(tag.persistentModelID)
|
||
}
|
||
}
|
||
|
||
private func save() {
|
||
let tags = allTags.filter { selectedTags.contains($0.persistentModelID) }
|
||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||
if let action {
|
||
action.name = trimmed
|
||
action.symbolName = symbolName
|
||
action.trackingType = trackingType
|
||
action.tags = tags
|
||
LocalPrefs.setPromptsForNote(action.uuid, enabled: promptsForNote)
|
||
} else {
|
||
// sortOrder는 로컬 배치 순서가 없는 기기(첫 실행·다른 기기)에서의 폴백 정렬용으로만 기록
|
||
let maxOrder = allActions.map(\.sortOrder).max() ?? -1
|
||
let newAction = Action(
|
||
name: trimmed,
|
||
symbolName: symbolName,
|
||
trackingType: trackingType,
|
||
sortOrder: maxOrder + 1
|
||
)
|
||
newAction.tags = tags
|
||
context.insert(newAction)
|
||
// 이 기기의 로컬 배치 맨 끝에 추가 + 메모 창 설정 저장
|
||
LocalPrefs.appendActionToOrder(newAction.uuid)
|
||
LocalPrefs.setPromptsForNote(newAction.uuid, enabled: promptsForNote)
|
||
}
|
||
DataChange.commit(context: context)
|
||
dismiss()
|
||
}
|
||
}
|