mycode/myApp/HaruDanim/Shared/LocalPrefs.swift
songyc macbook 1dea41b472 feat(ux): 잠금화면 다짐 점 링 방식 + 즐겨찾기 순서 변경 시트 (실기기 피드백 2건)
첫 출시 전 반영 요청된 사용성 개선 — 둘 다 표시 계층·기기 로컬 설정만
건드리는 안전 범주.

① 잠금화면 위젯 다짐 점: '아래에서 차오르는 물높이' 채움은 5~6pt에서
   90%↔100% 차이(위쪽 1px)가 배경에 따라 사실상 안 보임 —
   **미완료 = 링(호) 진행(시작 최소 10%, 완료 직전에도 틈이 보이게
   최대 88%까지만 감김) / 완료 = 꽉 찬 원반**으로 교체. 링 끝의 틈은
   둘레에 생겨 눈에 띄고, 완료는 면적(빈 링 vs 원반) 자체가 달라
   어떤 배경에서도 구분된다. ratio 그대로라 '이하 유지'는
   한도 안=원반/초과=빈 트랙으로 의미 유지 (§10 문서화)

② 즐겨찾기 순서 변경: 행동 탭 ⋯ 메뉴에 '즐겨찾기 순서 변경' 시트
   추가(2개 이상일 때, 꼬리표 순서 시트와 동일 패턴 — 닫힐 때 1회
   커밋). LocalPrefs.reorderFavorites는 전역 배치에서 즐겨찾기가
   차지한 자리들만 재배열해 나머지 행동·위젯 기본 슬롯 무영향.
   모음 탭 즐겨찾기 섹션·애플워치 즐겨찾기가 같은 순서를 읽으므로
   세 표면이 자동 일관 (§6.2·§6.1·§11 문서화).
   move(fromOffsets:)는 SwiftUI 확장이라 Foundation 계층에 동일
   의미로 직접 구현

- 도움말 '즐겨찾기 모아 보기' 본문에 순서 변경 경로·워치 적용 추가
- 신규 문구 4키 en/ja 완역, 옛 본문 stale 정리 (missing/stale 0)
- §14에 -actionShowFavoriteOrder 검증 인자 추가
- 검증: Debug/Store 빌드, 잠금 위젯 프리뷰·순서 시트 스크린샷

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 21:38:16 +09:00

201 lines
10 KiB
Swift

//
// LocalPrefs.swift
// Haru_Danim
//
// UI (CloudKit )
//
// : ·· "" CloudKit ,
// " "( , , , )
// App Group UserDefaults iCloud .
// · ·App Intents App Group defaults
// .
//
// @Model (Action.sortOrder, Action.promptsForNote, Goal.showsOnMain,
// Goal.isCollapsed) CloudKit ( ),
// UI . 1 .
//
import Foundation
import SwiftData
nonisolated enum LocalPrefsKeys {
/// Action.uuid
static let actionOrder = "local.actionOrder"
/// Goal.uuid
static let pinnedGoals = "local.pinnedGoals"
/// Goal.uuid
static let collapsedGoals = "local.collapsedGoals"
/// Action.uuid
static let notePromptActions = "local.notePromptActions"
/// Goal.uuid .
/// ( ) · .
/// .
static let watchGoals = "local.watchGoals"
/// ' ' ( ) Bool
static let mainOthersCollapsed = "local.mainOthersCollapsed"
}
nonisolated enum LocalPrefs {
static var defaults: UserDefaults { AppGroup.defaults }
// MARK: ( UUID )
static func idList(_ raw: String) -> [UUID] {
raw.split(separator: ",").compactMap { UUID(uuidString: String($0)) }
}
static func rawValue(_ ids: [UUID]) -> String {
ids.map(\.uuidString).joined(separator: ",")
}
static func contains(_ id: UUID, in raw: String) -> Bool {
raw.contains(id.uuidString)
}
/// ( , , ) id raw
static func toggling(_ id: UUID, in raw: String) -> String {
var ids = idList(raw)
if let index = ids.firstIndex(of: id) {
ids.remove(at: index)
} else {
ids.append(id)
}
return rawValue(ids)
}
// MARK:
/// .
/// : ( CloudKit )
/// . () sortOrder .
static func orderedActions(_ actions: [Action], raw: String) -> [Action] {
var position: [UUID: Int] = [:]
for (index, id) in idList(raw).enumerated() {
position[id] = index
}
return actions.sorted {
switch (position[$0.uuid], position[$1.uuid]) {
case let (a?, b?): return a < b
case (.some, nil): return true
case (nil, .some): return false
case (nil, nil):
if $0.sortOrder != $1.sortOrder { return $0.sortOrder < $1.sortOrder }
return $0.createdAt < $1.createdAt
}
}
}
static func orderedActions(_ actions: [Action]) -> [Action] {
orderedActions(actions, raw: defaults.string(forKey: LocalPrefsKeys.actionOrder) ?? "")
}
/// ( )
static func saveActionOrder(_ actions: [Action]) {
defaults.set(rawValue(actions.map(\.uuid)), forKey: LocalPrefsKeys.actionOrder)
}
/// .
/// ( )
/// (sortOrder) .
static func appendActionToOrder(_ id: UUID) {
guard let raw = defaults.string(forKey: LocalPrefsKeys.actionOrder) else { return }
var ids = idList(raw)
guard !ids.contains(id) else { return }
ids.append(id)
defaults.set(rawValue(ids), forKey: LocalPrefsKeys.actionOrder)
}
/// '' : .
/// " "
/// ( ),
/// ( ) (§6.1 v2)
static func placeNewFavoriteAtEnd(_ action: Action, context: ModelContext) {
let all = (try? context.fetch(FetchDescriptor<Action>())) ?? []
var ordered = orderedActions(all)
guard let from = ordered.firstIndex(where: { $0.uuid == action.uuid }) else { return }
let moved = ordered.remove(at: from)
guard let lastFavorite = ordered.lastIndex(where: { $0.isFavorite }) else { return }
ordered.insert(moved, at: lastFavorite + 1)
saveActionOrder(ordered)
}
/// ( )
/// '' , .
/// ( N)
/// . · (§6.1 v2·§11)
static func reorderFavorites(from source: IndexSet, to destination: Int, context: ModelContext) {
let all = (try? context.fetch(FetchDescriptor<Action>())) ?? []
var ordered = orderedActions(all)
let slots = ordered.indices.filter { ordered[$0].isFavorite }
var favorites = slots.map { ordered[$0] }
// move(fromOffsets:toOffset:) SwiftUI (Foundation )
// (destination )
let moving = source.sorted(by: >).map { favorites.remove(at: $0) }.reversed()
let insertAt = destination - source.count(where: { $0 < destination })
favorites.insert(contentsOf: moving, at: insertAt)
for (slot, action) in zip(slots, favorites) {
ordered[slot] = action
}
saveActionOrder(ordered)
}
// MARK: ( )
static func promptsForNote(_ id: UUID) -> Bool {
contains(id, in: defaults.string(forKey: LocalPrefsKeys.notePromptActions) ?? "")
}
static func setPromptsForNote(_ id: UUID, enabled: Bool) {
let raw = defaults.string(forKey: LocalPrefsKeys.notePromptActions) ?? ""
let current = contains(id, in: raw)
guard current != enabled else { return }
defaults.set(toggling(id, in: raw), forKey: LocalPrefsKeys.notePromptActions)
}
// MARK: ( )
static func showsOnWatch(_ id: UUID) -> Bool {
contains(id, in: defaults.string(forKey: LocalPrefsKeys.watchGoals) ?? "")
}
static func setShowsOnWatch(_ id: UUID, enabled: Bool) {
let raw = defaults.string(forKey: LocalPrefsKeys.watchGoals) ?? ""
guard contains(id, in: raw) != enabled else { return }
defaults.set(toggling(id, in: raw), forKey: LocalPrefsKeys.watchGoals)
}
// MARK: @Model 1
/// , ( ).
/// (: CloudKit )
/// .
@MainActor
static func adoptLegacyModelValuesIfNeeded(context: ModelContext) {
let actions = (try? context.fetch(FetchDescriptor<Action>(
sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
))) ?? []
if defaults.string(forKey: LocalPrefsKeys.actionOrder) == nil, !actions.isEmpty {
saveActionOrder(actions)
}
if defaults.string(forKey: LocalPrefsKeys.notePromptActions) == nil, !actions.isEmpty {
defaults.set(
rawValue(actions.filter(\.promptsForNote).map(\.uuid)),
forKey: LocalPrefsKeys.notePromptActions
)
}
let goals = (try? context.fetch(FetchDescriptor<Goal>())) ?? []
if defaults.string(forKey: LocalPrefsKeys.pinnedGoals) == nil, !goals.isEmpty {
defaults.set(
rawValue(goals.filter(\.showsOnMain).map(\.uuid)),
forKey: LocalPrefsKeys.pinnedGoals
)
}
if defaults.string(forKey: LocalPrefsKeys.collapsedGoals) == nil, !goals.isEmpty {
defaults.set(
rawValue(goals.filter(\.isCollapsed).map(\.uuid)),
forKey: LocalPrefsKeys.collapsedGoals
)
}
}
}