Radial menu icon order (iPhone floating nav only): - Settings → 내비게이션 gains a '반원 메뉴 순서 편집' link (shown only when the radial style is selected) that opens a drag-to-reorder list of all 7 tabs. - Order is stored device-local via @AppStorage(SettingsKeys.radialTabOrder) (not CloudKit-synced); RadialMenu reads it through AppTab.radialOrder(from:), which always yields the full tab set and appends any missing/unknown value in default spec order — so partial or legacy strings can never drop a tab. - iPad/Mac sidebars and the watch target are untouched (they never read the key). History timetable date step: - The date header </> buttons now jump by a full week (7 days) when the timetable is in '일주일' view, and by 1 day otherwise (list / daily timetable). - The calendar popover and '오늘' still set the date directly, so those paths and record loading are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
460 lines
20 KiB
Swift
460 lines
20 KiB
Swift
//
|
|
// SettingsView.swift
|
|
// Haru_Danim
|
|
//
|
|
// 설정 탭 (CLAUDE.md §6.6)
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
import StoreKit
|
|
import WidgetKit
|
|
|
|
struct SettingsView: View {
|
|
@Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal]
|
|
// 테마는 위젯('앱 테마와 일치' 옵션)이 읽어야 하므로 App Group defaults에 저장
|
|
@AppStorage(SettingsKeys.theme, store: AppGroup.defaults) private var theme = "light"
|
|
@AppStorage(SettingsKeys.language) private var language = AppLanguage.ko.rawValue
|
|
// 집계 기준 설정은 위젯·워치와 공유해야 하므로 App Group defaults에 저장
|
|
@AppStorage(SettingsKeys.weekStartWeekday, store: AppGroup.defaults) private var weekStartWeekday = 2
|
|
@AppStorage(SettingsKeys.dayStartMinutes, store: AppGroup.defaults) private var dayStartMinutes = 0
|
|
@AppStorage(SettingsKeys.liveActivityMode, store: AppGroup.defaults) private var liveActivityMode = LiveActivityMode.latest.rawValue
|
|
@AppStorage(SettingsKeys.minSessionSeconds, store: AppGroup.defaults) private var minSessionSeconds = 0
|
|
private let premium = PremiumManager.shared
|
|
@AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs
|
|
// 내비게이션 방식은 이 기기에서만 쓰는 UI 설정 → 동기화하지 않는 standard 저장소 사용
|
|
@AppStorage(SettingsKeys.navStyle) private var navStyleRaw = NavStyle.tabBar.rawValue
|
|
@AppStorage(SettingsKeys.goalCardStyle) private var goalCardStyle = GoalCardStyle.perQuest.rawValue
|
|
// 모음 탭 노출 목표는 기기별 로컬 설정 (CloudKit 동기화 제외, LocalPrefs 참고)
|
|
@AppStorage(LocalPrefsKeys.pinnedGoals, store: AppGroup.defaults) private var pinnedGoalsRaw = ""
|
|
|
|
private var visibleTabs: [AppTab] {
|
|
AppTab.visibleTabs(from: visibleTabsRaw)
|
|
}
|
|
|
|
/// 하루 시작 시간을 DatePicker와 연결하기 위한 변환
|
|
private var dayStartDate: Binding<Date> {
|
|
Binding {
|
|
let cal = Calendar.current
|
|
return cal.date(byAdding: .minute, value: dayStartMinutes, to: cal.startOfDay(for: .now))!
|
|
} set: { newValue in
|
|
let cal = Calendar.current
|
|
let comps = cal.dateComponents([.hour, .minute], from: newValue)
|
|
dayStartMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollViewReader { proxy in
|
|
Form {
|
|
Section {
|
|
Picker("테마", selection: $theme) {
|
|
Text("라이트").tag("light")
|
|
Text("다크").tag("dark")
|
|
}
|
|
Picker("언어", selection: $language) {
|
|
ForEach(AppLanguage.allCases) { lang in
|
|
Text(lang.label).tag(lang.rawValue)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("화면")
|
|
} footer: {
|
|
Text("언어를 바꾸면 앱을 완전히 종료했다가 다시 실행했을 때 적용돼요.")
|
|
}
|
|
// iPad·Mac은 사이드바에 모든 탭이 항상 보이므로 내비게이션·탭바 구성 설정이 무의미하다
|
|
if !DeviceLayout.isPad {
|
|
Section {
|
|
Picker("내비게이션 방식", selection: $navStyleRaw) {
|
|
ForEach(NavStyle.allCases) { style in
|
|
Text(style.label).tag(style.rawValue)
|
|
}
|
|
}
|
|
// 반원 메뉴일 때만 아이콘 배치 순서를 편집할 수 있다
|
|
if navStyleRaw == NavStyle.radial.rawValue {
|
|
NavigationLink {
|
|
RadialOrderView()
|
|
} label: {
|
|
Label("반원 메뉴 순서 편집", systemImage: "arrow.up.arrow.down")
|
|
}
|
|
}
|
|
} header: {
|
|
Text("내비게이션")
|
|
} footer: {
|
|
Text("‘중앙 플로팅 반원 메뉴’를 고르면 하단 탭바 대신 화면 하단 중앙의 버튼을 눌러 모든 탭을 반원으로 펼쳐 이동해요. (실험적 기능)")
|
|
}
|
|
// 반원 메뉴는 모든 탭을 항상 노출하므로 노출 탭 선택이 필요 없다
|
|
if navStyleRaw == NavStyle.tabBar.rawValue {
|
|
Section {
|
|
ForEach(AppTab.allCases) { tab in
|
|
tabToggleRow(tab)
|
|
}
|
|
} header: {
|
|
Text("탭바 구성")
|
|
} footer: {
|
|
Text("선택한 탭(최대 \(TabBarConfig.maxVisible)개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요.")
|
|
}
|
|
}
|
|
}
|
|
Section {
|
|
if goals.isEmpty {
|
|
Text("목표 탭에서 목표를 만들면 여기서 선택할 수 있어요.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(goals) { goal in
|
|
pinnedGoalRow(goal)
|
|
}
|
|
Picker("표시 방식", selection: $goalCardStyle) {
|
|
ForEach(GoalCardStyle.allCases) { style in
|
|
Text(style.label).tag(style.rawValue)
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text("모음 탭 목표 진행 현황")
|
|
} footer: {
|
|
Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 카드를 옆으로 쓸어 넘겨 한 장씩 볼 수 있고, 표시 방식(다짐별로 각각 / 전체 다짐 합산)은 모든 카드에 똑같이 적용돼요.")
|
|
}
|
|
.id("goalSection")
|
|
Section {
|
|
Picker("주 시작 요일", selection: $weekStartWeekday) {
|
|
ForEach(1...7, id: \.self) { weekday in
|
|
Text("\(Format.weekdayShort(weekday))요일").tag(weekday)
|
|
}
|
|
}
|
|
DatePicker("하루 시작 시간", selection: dayStartDate, displayedComponents: .hourAndMinute)
|
|
} header: {
|
|
Text("날짜 기준")
|
|
} footer: {
|
|
Text("하루 시작 시간을 걸치는 기록은 통계에서 자동으로 날짜별로 나누어 계산돼요.")
|
|
}
|
|
Section {
|
|
Picker("짧은 기록 무시", selection: $minSessionSeconds) {
|
|
ForEach(MinSessionOption.choices, id: \.seconds) { option in
|
|
Text(option.label).tag(option.seconds)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("측정")
|
|
} footer: {
|
|
Text("설정한 시간보다 짧게 측정하고 종료한 기록은 저장하지 않아요. 버튼을 실수로 눌렀을 때 유용해요.")
|
|
}
|
|
Section {
|
|
Picker("대표 시간 기준", selection: $liveActivityMode) {
|
|
ForEach(LiveActivityMode.allCases) { mode in
|
|
Text(mode.label).tag(mode.rawValue)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("다이나믹 아일랜드 · 잠금화면")
|
|
} footer: {
|
|
Text("여러 행동을 동시에 추적 중일 때 대표로 표시할 시간이에요. (Live Activity 지원은 추후 추가 예정)")
|
|
}
|
|
Section("프리미엄") {
|
|
NavigationLink {
|
|
PremiumView()
|
|
} label: {
|
|
HStack {
|
|
Label("프리미엄 기능", systemImage: "crown.fill")
|
|
.foregroundStyle(AppTheme.yellow)
|
|
Spacer()
|
|
Text(premium.isPremium ? String(localized: "사용 중") : String(localized: "무료 사용 중"))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.id("premiumSection")
|
|
Section {
|
|
HStack {
|
|
Text("버전")
|
|
Spacer()
|
|
Text("1.0").foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
.background(AppTheme.background)
|
|
.navigationTitle("설정")
|
|
.onChange(of: theme) {
|
|
// '앱 테마와 일치' 옵션의 위젯이 바뀐 테마를 바로 반영하도록 갱신
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
.onChange(of: weekStartWeekday) {
|
|
// 주/하루 경계가 바뀌면 위젯의 누적·진행률 집계도 달라진다
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
.onChange(of: dayStartMinutes) {
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
.onChange(of: language) {
|
|
// 시스템의 앱별 언어 오버라이드 방식: 다음 실행부터 적용된다
|
|
UserDefaults.standard.set([language], forKey: "AppleLanguages")
|
|
}
|
|
#if DEBUG
|
|
// 검증용: -settingsScrollGoal YES → 모음 탭 목표 섹션, -settingsScrollPremium YES → 프리미엄 섹션까지 자동 스크롤
|
|
.onAppear {
|
|
if UserDefaults.standard.bool(forKey: "settingsScrollGoal") {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
withAnimation { proxy.scrollTo("goalSection", anchor: .top) }
|
|
}
|
|
}
|
|
if UserDefaults.standard.bool(forKey: "settingsScrollPremium") {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
withAnimation { proxy.scrollTo("premiumSection", anchor: .top) }
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// MARK: 모음 탭 목표 선택 (개수 제한 없는 다중 선택)
|
|
|
|
private func pinnedGoalRow(_ goal: Goal) -> some View {
|
|
Button {
|
|
pinnedGoalsRaw = LocalPrefs.toggling(goal.uuid, in: pinnedGoalsRaw)
|
|
} label: {
|
|
HStack {
|
|
Label {
|
|
Text(goal.title)
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
} icon: {
|
|
Image(systemName: goal.symbolName)
|
|
.foregroundStyle(goal.color)
|
|
}
|
|
Spacer()
|
|
if LocalPrefs.contains(goal.uuid, in: pinnedGoalsRaw) {
|
|
Image(systemName: "checkmark")
|
|
.foregroundStyle(AppTheme.green)
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// MARK: 탭바 구성
|
|
|
|
private func tabToggleRow(_ tab: AppTab) -> some View {
|
|
let isVisible = visibleTabs.contains(tab)
|
|
let atMax = visibleTabs.count >= TabBarConfig.maxVisible
|
|
let atMin = visibleTabs.count <= TabBarConfig.minVisible
|
|
return Button {
|
|
toggleTab(tab)
|
|
} label: {
|
|
HStack {
|
|
Label {
|
|
Text(tab.label)
|
|
.foregroundStyle(.primary)
|
|
} icon: {
|
|
Image(systemName: tab.symbol)
|
|
.foregroundStyle(AppTheme.green)
|
|
}
|
|
Spacer()
|
|
if isVisible {
|
|
Image(systemName: "checkmark")
|
|
.foregroundStyle(AppTheme.green)
|
|
.fontWeight(.semibold)
|
|
} else {
|
|
Text("더보기")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled((isVisible && atMin) || (!isVisible && atMax))
|
|
.opacity((isVisible && atMin) || (!isVisible && atMax) ? 0.5 : 1)
|
|
}
|
|
|
|
private func toggleTab(_ tab: AppTab) {
|
|
var current = visibleTabs
|
|
if let index = current.firstIndex(of: tab) {
|
|
guard current.count > TabBarConfig.minVisible else { return }
|
|
current.remove(at: index)
|
|
} else {
|
|
guard current.count < TabBarConfig.maxVisible else { return }
|
|
current.append(tab)
|
|
}
|
|
// 스펙 탭 순서(메인→행동→꼬리표→목표→기록→설정) 유지
|
|
let ordered = AppTab.allCases.filter { current.contains($0) }
|
|
visibleTabsRaw = ordered.map(\.rawValue).joined(separator: ",")
|
|
}
|
|
}
|
|
|
|
// MARK: - 반원(플로팅) 메뉴 아이콘 순서 편집 (iPhone 반원 모드 전용)
|
|
|
|
/// 반원 메뉴를 펼쳤을 때 왼쪽 끝 → 위 → 오른쪽 끝으로 배치될 탭 순서를 드래그로 바꾼다.
|
|
/// 순서는 기기 로컬(비동기화)로만 저장되며, iPad·Mac 사이드바 순서에는 영향을 주지 않는다.
|
|
struct RadialOrderView: View {
|
|
@AppStorage(SettingsKeys.radialTabOrder) private var radialOrderRaw = ""
|
|
@State private var order: [AppTab] = []
|
|
|
|
var body: some View {
|
|
List {
|
|
Section {
|
|
ForEach(order) { tab in
|
|
HStack(spacing: 12) {
|
|
Image(systemName: tab.symbol)
|
|
.foregroundStyle(AppTheme.green)
|
|
.frame(width: 28)
|
|
Text(tab.label)
|
|
}
|
|
}
|
|
.onMove { source, destination in
|
|
order.move(fromOffsets: source, toOffset: destination)
|
|
radialOrderRaw = order.map(\.rawValue).joined(separator: ",")
|
|
}
|
|
} footer: {
|
|
Text("반원 메뉴를 펼쳤을 때 왼쪽 끝에서 오른쪽 끝으로 이 순서대로 아이콘이 배치돼요. 손잡이를 끌어 순서를 바꾸세요.")
|
|
}
|
|
}
|
|
.environment(\.editMode, .constant(.active))
|
|
.scrollContentBackground(.hidden)
|
|
.background(AppTheme.background)
|
|
.navigationTitle("반원 메뉴 순서")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.onAppear { order = AppTab.radialOrder(from: radialOrderRaw) }
|
|
}
|
|
}
|
|
|
|
// MARK: - 프리미엄 안내
|
|
|
|
struct PremiumView: View {
|
|
private let premium = PremiumManager.shared
|
|
private let store = PremiumStore.shared
|
|
@AppStorage(DataStore.cloudSyncKey, store: AppGroup.defaults) private var cloudSync = false
|
|
|
|
var body: some View {
|
|
List {
|
|
Section {
|
|
featureRow("infinity", "개수 제한 해제", "행동·목표·다짐을 무제한으로 만들 수 있어요.")
|
|
featureRow("square.grid.2x2.fill", "홈·잠금화면 위젯", "위젯에서 바로 행동을 실행하고 달성률을 확인해요.")
|
|
featureRow("icloud.fill", "아이패드 동기화", "iCloud로 기기 간 데이터가 동기화돼요.")
|
|
featureRow("applewatch", "애플워치 앱", "워치에서 추적하고 컴플리케이션으로 확인해요.")
|
|
} header: {
|
|
Text("프리미엄으로 할 수 있는 것")
|
|
} footer: {
|
|
Text("무료: 행동 \(FreeLimits.actions)개 · 목표 \(FreeLimits.goals)개 · 목표당 다짐 \(FreeLimits.questsPerGoal)개까지")
|
|
}
|
|
if premium.isPremium {
|
|
Section {
|
|
Toggle("기기 간 동기화", isOn: $cloudSync)
|
|
} header: {
|
|
Text("동기화")
|
|
} footer: {
|
|
Text("iCloud로 아이패드 등 다른 기기와 데이터가 동기화돼요. 변경 사항은 앱을 완전히 종료했다가 다시 실행하면 적용돼요.")
|
|
}
|
|
} else {
|
|
purchaseSection
|
|
}
|
|
#if DEBUG
|
|
Section("개발용") {
|
|
Toggle("프리미엄 상태 (테스트)", isOn: Binding(
|
|
get: { premium.isPremium },
|
|
set: { premium.setMockPremium($0) }
|
|
))
|
|
}
|
|
#endif
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
.background(AppTheme.background)
|
|
.navigationTitle("프리미엄")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar(.hidden, for: .tabBar)
|
|
.task {
|
|
await store.loadProducts()
|
|
}
|
|
}
|
|
|
|
// MARK: 구매 (StoreKit 2)
|
|
|
|
@ViewBuilder
|
|
private var purchaseSection: some View {
|
|
Section {
|
|
if store.products.isEmpty {
|
|
if store.isLoading {
|
|
HStack {
|
|
ProgressView()
|
|
Text("상품 정보를 불러오는 중...")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else {
|
|
// App Store Connect에 상품 등록 전이거나 네트워크 문제
|
|
Label("상품 정보를 불러올 수 없어요", systemImage: "wifi.exclamationmark")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
Button("다시 시도") {
|
|
Task { await store.loadProducts() }
|
|
}
|
|
}
|
|
} else {
|
|
ForEach(store.products) { product in
|
|
productRow(product)
|
|
}
|
|
}
|
|
Button {
|
|
Task { await store.restore() }
|
|
} label: {
|
|
Text("구매 복원")
|
|
.font(.callout)
|
|
}
|
|
.disabled(store.isPurchasing)
|
|
} header: {
|
|
Text("프리미엄 구매")
|
|
} footer: {
|
|
if let message = store.lastErrorMessage {
|
|
Text(message).foregroundStyle(.red)
|
|
} else {
|
|
Text("구독은 언제든 해지할 수 있고, 평생 이용권은 한 번 결제로 모든 기능을 계속 사용해요.")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func productRow(_ product: Product) -> some View {
|
|
Button {
|
|
Task { await store.purchase(product) }
|
|
} label: {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(product.displayName)
|
|
.font(.body.weight(.semibold))
|
|
.foregroundStyle(.primary)
|
|
if !product.description.isEmpty {
|
|
Text(product.description)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
Text(product.displayPrice)
|
|
.font(.body.weight(.bold))
|
|
.foregroundStyle(AppTheme.green)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(store.isPurchasing)
|
|
}
|
|
|
|
private func featureRow(_ symbol: String, _ title: String, _ detail: String) -> some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: symbol)
|
|
.font(.system(size: 18))
|
|
.foregroundStyle(AppTheme.green)
|
|
.frame(width: 30)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title).font(.body.weight(.semibold))
|
|
Text(detail).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|