// // 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 { 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 { 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 { 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) } } // 짧은 기록 검사는 종료를 실행한 기기 기준(§6.8) — 기기마다 값이 다르면 // 교차 사용(아이패드 시작→아이폰 종료)에서 결과가 갈리므로 값을 iCloud로 통일 .onChange(of: minSessionSeconds) { _, newValue in SettingsCloudSync.shared.push(newValue, forKey: SettingsKeys.minSessionSeconds) } } header: { Text("측정") } footer: { Text("설정한 시간보다 짧게 측정하고 종료한 기록은 저장하지 않아요. 버튼을 실수로 눌렀을 때 유용해요.\n이 설정은 iCloud를 통해 모든 기기에서 동일하게 적용돼요 — 한 기기에서 바꾸면 아이폰·아이패드·맥에 함께 반영되고, 애플워치에서 끝낸 측정은 페어링된 아이폰의 설정을 따라요.") } .id("measureSection") Section { Picker("장시간 측정 알림", selection: $longSessionAlertHours) { ForEach(LongSessionAlertOption.choices, id: \.hours) { option in Text(option.label).tag(option.hours) } } } footer: { Text("측정이 설정한 시간을 넘기면 알려줘요 — 끄는 걸 잊었을 때 유용해요. 이 알림 설정은 기기마다 따로 저장돼요.") } Section { Picker("대표 시간 기준", selection: $liveActivityMode) { ForEach(LiveActivityMode.allCases) { mode in Text(mode.label).tag(mode.rawValue) } } } header: { Text("다이나믹 아일랜드 · 잠금화면") } footer: { Text("여러 행동을 동시에 추적 중일 때 다이나믹 아일랜드·잠금화면과 워치 컴플리케이션에 대표로 표시할 행동이에요.") } // 일기는 아이패드·맥에서 사용(§6.7) — 잠금 설정도 그 기기에서만 의미가 있다 if DeviceLayout.isPad { 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") } NavigationLink { DataImportView() } label: { Label("데이터 가져오기", systemImage: "square.and.arrow.down") } NavigationLink { DataResetView() } label: { Label("데이터 정리·초기화", systemImage: "trash") } } header: { Text("데이터") } footer: { Text("모든 기록을 CSV 파일로 만들어 백업하거나 스프레드시트에서 열 수 있어요. '데이터 가져오기'(프리미엄)로는 내보냈던 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(safeSymbol: 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("square.and.arrow.down", "CSV 가져오기", "내보냈던 CSV에서 행동과 기록을 복원해요.") 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() } } } } } }