mycode/myApp/HaruDanim/IOS/Views/ActionViews.swift
songyc macbook 92afa69f6d feat(1.5-p1): 워치 '행동 기록' 컴플리케이션 — 기간 옵트인·미니 행동 화면 딥링크
- WatchActionPeriod(하루/이번 주/이번 달/지난 7일/지난 30일) + WatchActionInfo에
  optional 기간 필드 3종(구버전 캐시 호환 — isFavorite 패턴)
- LocalPrefs.watchActions(uuid→기간 맵, 기기 로컬) + 전체 초기화 청소 목록 반영
- makeSnapshot: 옵트인 행동만 기간값 계산(rollingRange 재사용, StatSpan 무변경)
- 신규 ActionValueComplication(4패밀리·측정 중 노랑+타이머·유령 타이머 안전장치·
  빈 상태 안내·recommendations verbatim·widgetURL 딥링크)
- 워치 미니 행동 화면(WatchActionDetailView) + NavigationPath 딥링크 착지
  (뒤로가기 한 번에 메인 복귀), 행동 편집기 애플워치 섹션(토글+기간)
- 검증 인자: -watchActions <N>·-actionShowEditor·-watchOpenActionDetail
- 검증: Debug/Store/워치(26.2·11.5) 빌드, 자가 검증 49·20·27·10 ALL PASS(26.5+18.5 심),
  시각 QA 5장. 신규 문구 l10n은 계획 §10 R6대로 도움말 단계에서 일괄
- CLAUDE.md: 시뮬레이터 세트 리셋 반영(신규 UUID)·검증 인자 추가

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
2026-08-22 05:18:42 +09:00

672 lines
28 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
@State private var showingFavoriteOrder: Bool = {
#if DEBUG
// : -actionShowFavoriteOrder YES
if UserDefaults.standard.bool(forKey: "actionShowFavoriteOrder") { return true }
#endif
return false
}()
@State private var showingFirstEditor: Bool = {
#if DEBUG
// : -actionShowEditor YES
// (1.5 )
if UserDefaults.standard.bool(forKey: "actionShowEditor") { return true }
#endif
return 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")
}
// 2
if favoriteActions.count >= 2 {
Button {
showingFavoriteOrder = true
} label: {
Label("즐겨찾기 순서 변경", systemImage: "star.fill")
}
}
} label: {
Image(systemName: "ellipsis.circle")
}
.accessibilityLabel(Text("메뉴"))
}
ToolbarItem(placement: .topBarTrailing) {
Button {
if !premium.canAddAction(currentCount: actions.count) {
showLimitAlert = true
} else {
showingAdd = true
}
} label: {
Image(systemName: "plus")
}
.accessibilityLabel(Text("행동 추가"))
}
}
.sheet(isPresented: $showingAdd) {
ActionEditorView(action: nil)
}
.sheet(isPresented: $showingTagOrder) {
TagOrderSheet()
}
.sheet(isPresented: $showingFavoriteOrder) {
FavoriteOrderSheet()
}
.sheet(isPresented: $showingFirstEditor) {
if let first = actions.first {
ActionEditorView(action: first)
}
}
.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()
// (§6.1 " " )
if action.isFavorite { LocalPrefs.placeNewFavoriteAtEnd(action, context: context) }
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(safeSymbol: 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 FavoriteOrderSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var context
@Query(sort: \Action.createdAt) private var allActions: [Action]
//
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
///
/// · (TagOrderSheet )
@State private var orderChanged = false
private var favorites: [Action] {
LocalPrefs.orderedActions(allActions, raw: actionOrderRaw).filter(\.isFavorite)
}
var body: some View {
NavigationStack {
List {
Section {
ForEach(favorites) { action in
HStack(spacing: 10) {
Image(safeSymbol: action.symbolName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(action.color, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
Text(action.name)
.lineLimit(1)
}
}
.onMove { source, destination in
LocalPrefs.reorderFavorites(from: source, to: destination, context: context)
orderChanged = true
}
} footer: {
Text("손잡이를 끌어 순서를 바꿔요. 모음 탭 '즐겨찾기'와 애플워치의 즐겨찾기 목록에 같은 순서로 적용돼요.")
}
}
.environment(\.editMode, .constant(.active))
.navigationTitle("즐겨찾기 순서")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("완료") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
.onDisappear {
// · 1
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
// (§6.1 " " )
if $0 { LocalPrefs.placeNewFavoriteAtEnd(action, context: context) }
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(safeSymbol: 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
/// ' ' + (LocalPrefs, 1.5)
@State private var showsComplication = false
@State private var complicationPeriod: WatchActionPeriod = .day
@State private var selectedTags: Set<PersistentIdentifier> = []
@State private var showingSymbolPicker = false
@State private var showingNewTag = false
var body: some View {
NavigationStack {
ScrollViewReader { editorScrollProxy in
Form {
Section("이름") {
TextField("행동 이름 (예: 독서)", text: $name)
}
Section("아이콘") {
Button {
showingSymbolPicker = true
} label: {
HStack {
Image(safeSymbol: 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: "켜면 횟수를 추가할 때마다 메모 창이 떠요. 건너뛸 수 있어요."))
}
// iPhone iPad
// ( ' ' 1.5)
if !DeviceLayout.isPad {
Section {
Toggle(isOn: $showsComplication.animation()) {
Label {
Text("애플워치 컴플리케이션")
} icon: {
Image(systemName: "applewatch")
.foregroundStyle(AppTheme.green)
}
}
if showsComplication {
Picker("표시 기간", selection: $complicationPeriod) {
ForEach(WatchActionPeriod.allCases, id: \.self) { period in
Text(period.label).tag(period)
}
}
}
} header: {
Text("애플워치")
} footer: {
Text("켜면 워치 페이스의 '행동 기록' 컴플리케이션에 이 행동을 올릴 수 있어요. 선택한 기간의 누적 기록이 표시되고, 컴플리케이션을 누르면 워치 앱에서 바로 실행할 수 있어요. 이 설정은 워치와 연결된 이 아이폰에만 저장돼요.")
}
.id("watchSection")
}
}
#if DEBUG
// -actionShowEditor YES
.onAppear {
if UserDefaults.standard.bool(forKey: "actionShowEditor") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
withAnimation { editorScrollProxy.scrollTo("watchSection", anchor: .bottom) }
}
}
}
#endif
.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)
if let raw = LocalPrefs.watchActionPeriodRaw(action.uuid),
let period = WatchActionPeriod(rawValue: raw) {
showsComplication = true
complicationPeriod = period
}
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)
LocalPrefs.setWatchActionPeriod(
action.uuid, raw: showsComplication ? complicationPeriod.rawValue : nil
)
} 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)
LocalPrefs.setWatchActionPeriod(
newAction.uuid, raw: showsComplication ? complicationPeriod.rawValue : nil
)
}
DataChange.commit(context: context)
dismiss()
}
}