mycode/myApp/HaruDanim/IOS/Views/SettingsView.swift
2026-07-10 03:02:15 +09:00

266 lines
11 KiB
Swift

//
// SettingsView.swift
// Haru_Danim
//
// (CLAUDE.md §6.6)
//
import SwiftUI
import SwiftData
struct SettingsView: View {
@Query(sort: \Goal.createdAt) private var goals: [Goal]
@AppStorage(SettingsKeys.theme) private var theme = "light"
@AppStorage(SettingsKeys.language) private var language = AppLanguage.ko.rawValue
@AppStorage(SettingsKeys.weekStartWeekday) private var weekStartWeekday = 2
@AppStorage(SettingsKeys.dayStartMinutes) private var dayStartMinutes = 0
@AppStorage(SettingsKeys.liveActivityMode) private var liveActivityMode = LiveActivityMode.latest.rawValue
@AppStorage(SettingsKeys.isPremium) private var isPremium = false
@AppStorage(SettingsKeys.minSessionSeconds) private var minSessionSeconds = 0
@AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs
@AppStorage(SettingsKeys.goalCardStyle) private var goalCardStyle = GoalCardStyle.perQuest.rawValue
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)
}
}
/// Goal.showsOnMain ( 1 )
private var pinnedGoalSelection: Binding<PersistentIdentifier?> {
Binding {
goals.first { $0.showsOnMain }?.persistentModelID
} set: { newValue in
for goal in goals {
goal.showsOnMain = goal.persistentModelID == newValue
}
}
}
var body: some View {
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)
}
}
}
Section {
ForEach(AppTab.allCases) { tab in
tabToggleRow(tab)
}
} header: {
Text("탭바 구성")
} footer: {
Text("선택한 탭(최대 \(TabBarConfig.maxVisible)개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요.")
}
Section {
Picker("목표 진행 현황 표시", selection: pinnedGoalSelection) {
Text("표시 안 함").tag(nil as PersistentIdentifier?)
ForEach(goals) { goal in
Text(goal.title).tag(Optional(goal.persistentModelID))
}
}
if goals.contains(where: \.showsOnMain) {
Picker("표시 방식", selection: $goalCardStyle) {
ForEach(GoalCardStyle.allCases) { style in
Text(style.label).tag(style.rawValue)
}
}
}
} header: {
Text("모음 탭")
} footer: {
Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. ‘다짐별로 각각’은 다짐(최대 3개)마다 하루·주간·월간 진행률을, ‘전체 다짐 합산’은 소속 다짐 전체의 평균 진행률을 한 줄로 보여줘요.")
}
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(isPremium ? "사용 중" : "무료 사용 중")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Section {
HStack {
Text("버전")
Spacer()
Text("1.0").foregroundStyle(.secondary)
}
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("설정")
}
// 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: -
struct PremiumView: View {
@AppStorage(SettingsKeys.isPremium) private var isPremium = 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 isPremium {
Section("동기화") {
Toggle("기기 간 동기화", isOn: .constant(false))
.disabled(true)
Text("iCloud 동기화는 준비 중이에요.")
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
Section {
Button {
// StoreKit Apple Developer Program (CLAUDE.md §2.3)
} label: {
Label("프리미엄 구매 (준비 중)", systemImage: "cart.fill")
}
.disabled(true)
} footer: {
Text("결제 기능은 App Store 배포 준비와 함께 제공될 예정이에요.")
}
}
#if DEBUG
Section("개발용") {
Toggle("프리미엄 상태 (테스트)", isOn: $isPremium)
}
#endif
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("프리미엄")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
}
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)
}
}