2026-07-23 사용성 감사(A그룹 5건 + 지시 추가분)를 한 번에 반영.
[테마] '시스템 설정 따름' 추가 + 기본값 light→system. "system"은 스킴을
강제하지 않아(preferredColorScheme nil) 앱·위젯('앱 일치' 옵션,
WidgetThemeOption.scheme: ColorScheme?)·DEBUG 위젯 미리보기까지
시스템 라이트/다크를 그대로 따른다
[온보딩] 첫 실행 3장 소개(OnboardingView — 행동→목표·다짐→정리).
데이터가 하나도 없고 본 적 없을 때만 1회(건너뛰기 포함 재표시 없음,
onboarding.done), 시드·마케팅 촬영 플로우는 자동 건너뜀.
설정 → 지원 '앱 소개 다시 보기'로 재열람
[일기 잠금] 설정 → 일기(iPad 전용 표시) 토글 — DiaryLock이
.deviceOwnerAuthentication으로 Face ID/Touch ID/Optic ID+암호 폴백을
기기별 분기 없이 처리. 일기 탭 진입 게이트(DiaryLockGateView),
백그라운드 재잠금, 토글 변경 시 인증 요구, 잠글 수단 없는 기기는
통과(영구 잠김 방지). NSFaceIDUsageDescription 추가(Info.plist+
InfoPlist.xcstrings ko/en/ja)
[통계] 이전 기간 비교 카드(맨 위) — 총 시간·횟수를 어제/지난주/지난달과
비교(▲▼%, 이전 기록 없으면 상태 문구). 이전 기간은 관계 기반
Aggregator로 직접 집계, 필터 반영. 화면 전용(내보내기 미포함 의도)
[스플래시] 1.2→0.7초 단축 (하루에도 여러 번 여는 앱)
[워치] 행동 실행 탭 시 WKInterfaceDevice.play(.click) 햅틱 — 화면을
안 보고 탭해도 접수 확인
[컴플리케이션] rectangular 빈 상태에 해결 안내("목표 편집에서
'애플워치에서 보기'를 켜면 나타나요") — 옵트인 발견성 보완
[문구] 행동 편집기 꼬리표 색 규칙(여러 개면 가장 먼저 만든 꼬리표),
새로고침 버튼 accessibilityHint
- 도움말 3주제 추가(새로고침 버튼·일기 잠금·이전 기간과 비교)
- 신규 문구 31키 en/ja 완역 + 워치 카탈로그 2키, missing/stale 0
- CLAUDE.md §6·§6.6·§6.7·§6.8·§10·§11·§14 갱신, DEBUG 인자
-showOnboarding·-diaryLockScreen 추가
- 검증: Debug/워치/Store 빌드, 온보딩·설정·비교 카드·잠금 게이트·
컴플리케이션 스크린샷
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
607 lines
24 KiB
Swift
607 lines
24 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
|
||
@State private var showingFavoriteOrder: Bool = {
|
||
#if DEBUG
|
||
// 검증용: -actionShowFavoriteOrder YES → 즐겨찾기 순서 시트 바로 표시
|
||
if UserDefaults.standard.bool(forKey: "actionShowFavoriteOrder") { 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()
|
||
}
|
||
.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(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 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(systemName: 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(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()
|
||
}
|
||
}
|