mycode/myApp/HaruDanim/IOS/Views/SettingsView.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

742 lines
35 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 = "system"
@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
@AppStorage(SettingsKeys.longSessionAlertHours, store: AppGroup.defaults) private var longSessionAlertHours = 0
@Environment(\.modelContext) private var context
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.radialDoubleTapTab) private var radialDoubleTapTabRaw = ""
@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)
}
/// ' ' / rawValue on/off
private var radialDoubleTapBinding: Binding<Bool> {
Binding(
get: { !radialDoubleTapTabRaw.isEmpty },
set: { radialDoubleTapTabRaw = $0 ? AppTab.main.rawValue : "" }
)
}
/// ( )
@State private var showingOnboarding = false
/// (iPad DiaryLock )
@State private var diaryLock = DiaryLock.shared
///
private var diaryLockBinding: Binding<Bool> {
Binding(
get: { diaryLock.isEnabled },
set: { newValue in
diaryLock.authenticate(reason: String(localized: "일기 잠금 설정을 변경하려면 인증이 필요해요.")) { ok in
if ok { diaryLock.isEnabled = newValue }
}
}
)
}
/// ( )
@State private var showsDayStartWheel: Bool = {
#if DEBUG
// : -settingsShowDayStartWheel YES
if UserDefaults.standard.bool(forKey: "settingsShowDayStartWheel") { return true }
#endif
return false
}()
/// 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("system")
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")
}
Toggle("두 번 눌러 바로 이동", isOn: radialDoubleTapBinding)
if !radialDoubleTapTabRaw.isEmpty {
Picker("이동할 탭", selection: $radialDoubleTapTabRaw) {
ForEach(AppTab.phoneCases) { tab in
Text(tab.label).tag(tab.rawValue)
}
}
}
}
} header: {
Text("내비게이션")
} footer: {
if navStyleRaw == NavStyle.radial.rawValue {
Text("‘글래스 버블 메뉴’를 고르면 하단 탭바 대신 화면 하단 중앙의 버튼을 눌렀을 때 유리 구슬 모양의 탭 버튼들이 부채꼴로 퍼져요. 모든 탭이 엄지 반경 안에 있어 한 손으로 조작하기 편해요. (실험적 기능)\n‘두 번 눌러 바로 이동’을 켜면 메뉴가 닫혀 있을 때 버튼을 빠르게 두 번 눌러 선택한 탭으로 바로 이동해요.")
} else {
Text("‘글래스 버블 메뉴’를 고르면 하단 탭바 대신 화면 하단 중앙의 버튼을 눌렀을 때 유리 구슬 모양의 탭 버튼들이 부채꼴로 퍼져요. 모든 탭이 엄지 반경 안에 있어 한 손으로 조작하기 편해요. (실험적 기능)")
}
}
//
if navStyleRaw == NavStyle.tabBar.rawValue {
Section {
ForEach(AppTab.phoneCases) { 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)
}
}
// + (CollapsibleTimeWheel, §6.5)
CollapsibleTimeWheel(
label: String(localized: "하루 시작 시간"),
selection: dayStartDate,
components: .hourAndMinute,
initiallyExpanded: showsDayStartWheel
)
} header: {
Text("날짜 기준")
} footer: {
Text("하루 시작 시간을 걸치는 기록은 통계에서 자동으로 날짜별로 나누어 계산돼요.")
}
Section {
Picker("짧은 기록 무시", selection: $minSessionSeconds) {
ForEach(MinSessionOption.choices, id: \.seconds) { option in
Text(option.label).tag(option.seconds)
}
}
Picker("장시간 측정 알림", selection: $longSessionAlertHours) {
ForEach(LongSessionAlertOption.choices, id: \.hours) { option in
Text(option.label).tag(option.hours)
}
}
} header: {
Text("측정")
} footer: {
Text("설정한 시간보다 짧게 측정하고 종료한 기록은 저장하지 않아요. 버튼을 실수로 눌렀을 때 유용해요. 장시간 측정 알림은 측정이 설정한 시간을 넘기면 알려줘요 — 끄는 걸 잊었을 때 유용해요.\n이 설정들은 기기마다 따로 저장돼요. 짧은 기록 무시는 측정을 끝낸 기기의 설정이 적용되니, 아이폰과 아이패드를 함께 쓴다면 두 기기 모두 설정해 주세요. 애플워치에서 끝낸 측정은 페어링된 아이폰의 설정을 따라요.")
}
.id("measureSection")
Section {
Picker("대표 시간 기준", selection: $liveActivityMode) {
ForEach(LiveActivityMode.allCases) { mode in
Text(mode.label).tag(mode.rawValue)
}
}
} header: {
Text("다이나믹 아일랜드 · 잠금화면")
} footer: {
Text("여러 행동을 동시에 추적 중일 때 다이나믹 아일랜드·잠금화면과 워치 컴플리케이션에 대표로 표시할 행동이에요.")
}
// iPad ( , §6.7)
if DeviceLayout.isPad && !DeviceLayout.isMac {
Section {
Toggle(isOn: diaryLockBinding) {
Label {
Text("일기 잠금")
} icon: {
Image(systemName: "lock.fill")
.foregroundStyle(AppTheme.green)
}
}
} header: {
Text("일기")
} footer: {
Text("켜면 일기 탭을 열 때 Face ID·Touch ID 또는 기기 암호로 잠금을 해제해야 해요. 기기가 지원하는 방식이 자동으로 쓰이고, 앱을 나갔다 오면 다시 잠겨요. 이 설정은 이 기기에만 적용돼요.")
}
}
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 {
NavigationLink {
DataExportView()
} label: {
Label("데이터 내보내기", systemImage: "tablecells")
}
} header: {
Text("데이터")
} footer: {
Text("모든 기록을 CSV 파일로 만들어 백업하거나 스프레드시트에서 열 수 있어요.")
}
Section("지원") {
NavigationLink {
HelpView()
} label: {
Label("도움말", systemImage: "questionmark.circle")
}
Button {
showingOnboarding = true
} label: {
Label("앱 소개 다시 보기", systemImage: "sparkles")
.foregroundStyle(.primary)
}
HStack {
Text("버전")
Spacer()
// MARKETING_VERSION
Text(verbatim: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "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: longSessionAlertHours) { _, newValue in
// +
if newValue > 0 { SessionAlertManager.requestAuthorizationIfNeeded() }
LiveActivityManager.sync(context: context)
}
.onChange(of: language) {
// :
UserDefaults.standard.set([language], forKey: "AppleLanguages")
}
.fullScreenCover(isPresented: $showingOnboarding) {
OnboardingView { showingOnboarding = false }
}
#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) }
}
}
if UserDefaults.standard.bool(forKey: "settingsScrollMeasure") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { proxy.scrollTo("measureSection", 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.phoneCases.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: -
/// 3.1.2:
/// · .
/// Apple EULA ( EULA ).
private enum LegalLinks {
static let privacyPolicy = URL(string: "https://github.com/notalentprogrammer/HaruDanim/blob/main/PRIVACY.md")!
static let termsOfUse = URL(string: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/")!
}
struct PremiumView: View {
private let premium = PremiumManager.shared
private let store = PremiumStore.shared
@AppStorage(DataStore.cloudSyncKey, store: AppGroup.defaults) private var cloudSync = false
@State private var showingManageSubscriptions = false
var body: some View {
List {
Section {
featureRow("infinity", "개수 제한 해제", "행동·목표·다짐을 무제한으로 만들 수 있어요.")
featureRow("square.grid.2x2.fill", "홈·잠금화면 위젯", "위젯에서 바로 행동을 실행하고 달성률을 확인해요.")
featureRow("switch.2", "제어 센터에서 실행", "제어 센터·잠금화면 버튼·액션 버튼에서 행동을 바로 실행해요.")
featureRow("icloud.fill", "아이패드 동기화", "iCloud로 기기 간 데이터가 동기화돼요.")
featureRow("applewatch", "애플워치 앱", "워치에서 추적하고 컴플리케이션으로 확인해요.")
featureRow("book.closed.fill", "아이패드 일기", "하루 정리와 애플 펜슬 다이어리를 사용해요.")
featureRow("mic.fill", "시리 단축어", "\"시리야, 하루 다님에서 측정 시작\" — 말과 단축어 자동화로 기록해요.")
} header: {
Text("프리미엄으로 할 수 있는 것")
} footer: {
Text("무료: 행동 \(FreeLimits.actions)개 · 목표 \(FreeLimits.goals)개 · 목표당 다짐 \(FreeLimits.questsPerGoal)개까지")
}
if premium.isPremium {
currentPlanSection
Section {
Toggle("기기 간 동기화", isOn: $cloudSync)
} header: {
Text("동기화")
} footer: {
Text("iCloud로 아이패드 등 다른 기기와 데이터가 동기화돼요. 변경 사항은 앱을 완전히 종료했다가 다시 실행하면 적용돼요.")
}
//
if let plan = store.currentPlan, !plan.isLifetime,
let lifetime = store.products.first(where: { $0.id == PremiumProducts.lifetime }) {
Section {
productRow(lifetime)
} header: {
Text("평생 이용권으로 전환")
} footer: {
Text("평생 이용권을 구매한 뒤에는 구독 관리에서 기존 구독을 해지해 주세요. 결제가 겹치지 않게 남은 구독 기간이 끝날 즈음 구매하는 것도 좋은 방법이에요.")
}
}
} else {
purchaseSection
}
Section {
Link(destination: LegalLinks.privacyPolicy) {
legalRow("hand.raised.fill", "개인정보처리방침")
}
Link(destination: LegalLinks.termsOfUse) {
legalRow("doc.text.fill", "이용약관")
}
} header: {
Text("약관과 정책")
} footer: {
Text("이용약관은 Apple 표준 사용권 계약(EULA)이 적용돼요.")
}
#if DEBUG
Section {
// ( §15-6 )
Toggle(isOn: Binding(
get: { premium.isPremium },
set: { premium.setMockPremium($0) }
)) {
Text(verbatim: "프리미엄 상태 (테스트)")
}
Button {
Task {
await premium.clearMockOverride()
await store.refreshEntitlement()
}
} label: {
Text(verbatim: "토글 강제 해제 — 실제 구매 상태 따르기")
.font(.callout)
}
} header: {
Text(verbatim: "개발용")
} footer: {
Text(verbatim: "이 섹션은 개발(DEBUG) 빌드에서만 보여요. 출시(Release) 빌드와 'Haru_Danim-Store' 스킴 실행에는 나타나지 않아요. 토글을 한 번이라도 쓰면 실제 구매 상태를 무시하므로, 결제 검증 시에는 '토글 강제 해제'를 눌러 주세요.")
}
#endif
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("프리미엄")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
.manageSubscriptionsSheet(isPresented: $showingManageSubscriptions)
.task {
await store.loadProducts()
await store.refreshEntitlement()
}
}
// MARK:
@ViewBuilder
private var currentPlanSection: some View {
Section {
HStack(spacing: 12) {
Image(systemName: "crown.fill")
.font(.system(size: 20))
.foregroundStyle(AppTheme.yellow)
.frame(width: 30)
VStack(alignment: .leading, spacing: 2) {
if let plan = store.currentPlan {
Text(store.planDisplayName(plan))
.font(.body.weight(.semibold))
if plan.isLifetime {
Text("한 번 결제로 모든 기능을 계속 사용할 수 있어요")
.font(.caption)
.foregroundStyle(.secondary)
} else if let expiration = plan.expirationDate {
Text("다음 갱신: \(expiration.formatted(.dateTime.year().month().day()))")
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
Text("프리미엄 이용 중")
.font(.body.weight(.semibold))
}
}
}
if let plan = store.currentPlan, !plan.isLifetime {
Button {
showingManageSubscriptions = true
} label: {
Label("구독 관리 (플랜 변경·해지)", systemImage: "creditcard")
.font(.callout)
}
}
} header: {
Text("이용 중인 플랜")
} footer: {
if let plan = store.currentPlan, !plan.isLifetime {
Text("구독을 해지해도 이미 결제한 기간이 끝날 때까지는 계속 사용할 수 있어요.")
}
}
}
// 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("구독은 결제 확인 시 Apple 계정으로 청구되고, 기간이 끝나기 24시간 전까지 해지하지 않으면 자동 갱신돼요. 해지는 언제든 App Store 구독 관리에서 할 수 있어요. 평생 이용권은 한 번 결제로 모든 기능을 계속 사용해요. 기기를 바꿨다면 '구매 복원'을 눌러 주세요.")
}
}
}
private func productRow(_ product: Product) -> some View {
Button {
Task { await store.purchase(product) }
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(product.displayName)
.font(.body.weight(.semibold))
.foregroundStyle(.primary)
if let badge = savingsBadge(product) {
Text(badge)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(AppTheme.yellow, in: Capsule())
}
}
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 savingsBadge(_ product: Product) -> String? {
switch product.id {
case PremiumProducts.lifetime:
return String(localized: "한 번 결제")
case PremiumProducts.yearly:
guard let monthly = store.products.first(where: { $0.id == PremiumProducts.monthly }) else {
return nil
}
let monthlyYearTotal = NSDecimalNumber(decimal: monthly.price).doubleValue * 12
let yearly = NSDecimalNumber(decimal: product.price).doubleValue
guard monthlyYearTotal > yearly, monthlyYearTotal > 0 else { return nil }
let percent = Int(((monthlyYearTotal - yearly) / monthlyYearTotal * 100).rounded())
return String(localized: "\(percent)% 절약")
default:
return nil
}
}
/// · ( )
private func legalRow(_ symbol: String, _ title: LocalizedStringKey) -> some View {
HStack {
Label {
Text(title)
.foregroundStyle(.primary)
} icon: {
Image(systemName: symbol)
.foregroundStyle(AppTheme.green)
}
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
// title·detail LocalizedStringKey String
// · ( )
private func featureRow(_ symbol: String, _ title: LocalizedStringKey, _ detail: LocalizedStringKey) -> 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)
}
}
// MARK: - ( )
/// ·
struct PremiumSheetView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
PremiumView()
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("닫기") { dismiss() }
}
}
}
}
}