mycode/myApp/HaruDanim/IOS/Views/ActionViews.swift
songyc macbook ac40e14cfe feat(ux): UX 감사 반영 배치 — 시스템 테마·온보딩·일기 잠금·기간 비교 외
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>
2026-07-23 02:02:38 +09:00

607 lines
24 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
}()
// (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()
}
}