mycode/myApp/HaruDanim/IOS/Views/ActionViews.swift
songyc macbook 28a453485d feat(main): 배치 편집을 즐겨찾기 섹션 인지형으로 — 끌어서 지정·해제 + '지정 순서' 규칙
즐겨찾기 v2(e09a0b7) 후속 실사용 피드백 세 가지 반영:
①즐겨찾기 안에서 순서 바꾸기가 어렵다 ②새로 지정하면 꼬리표(전역)
순서에 끼어들어 예측이 안 된다 ③배치 편집이 즐겨찾기가 없다는 듯
통짜 그리드로 떠서 디테일이 떨어진다.

- 배치 편집도 즐겨찾기/나머지 섹션 구조 그대로(항상 펼침): 섹션 안
  드래그는 실시간 순서 변경, **섹션 사이 드래그는 드롭 확정 시점에
  즐겨찾기 지정·해제 + 그 위치로 이동**(commitDrop). 호버 중 교차
  이동은 자기 섹션 안 점프로 보여 moveDragging이 무시
- 빈 섹션은 점선 드롭 존(호버 하이라이트)이 받는다 — 즐겨찾기 0개
  상태에서도 끌어서 지정 가능, 전부 즐겨찾기면 해제 존 표시
- **새 즐겨찾기는 배치 순서상 블록 끝으로**(LocalPrefs.placeNewFavoriteAtEnd
  — 롱프레스 메뉴·행동 탭 스와이프·상세 토글 3경로 공통). 해제는
  위치 무이동(나머지 상단 근처에 남음), 첫 즐겨찾기도 무이동
- 도움말 본문에 순서 규칙·끌어서 지정 사용법 반영, en/ja 번역 +
  stale 1키 정리 (드롭 존 문구 2키 포함 missing/stale 0)
- 검증: Debug/Store 빌드, 아이폰·아이패드 편집 모드 + 브라우즈
  스크린샷 (isFavorite는 모델 필드라 DataChange.commit 경유 유지)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:18:45 +09:00

532 lines
21 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
// (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")
}
.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()
}
.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 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()
}
}