1214 lines
45 KiB
Swift
1214 lines
45 KiB
Swift
import SwiftUI
|
|
import RevenueCat
|
|
import RevenueCatUI
|
|
|
|
// MARK: - Settings
|
|
|
|
struct SettingsView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
@State private var showPaywall = false
|
|
@State private var showLogoutConfirm = false
|
|
@State private var showDeleteConfirm = false
|
|
@State private var isPairingActive = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: CloserSpacing.lg) {
|
|
Text("Manage your space, reminders, privacy, and account.")
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(.horizontal, CloserSpacing.xl)
|
|
|
|
if isLoggedInAnonymously {
|
|
NavigationLink {
|
|
SignUpView()
|
|
} label: {
|
|
SettingsProfileHeader(
|
|
initials: initials,
|
|
name: appState.currentUser?.displayName ?? "You",
|
|
detail: "Create an account to save your Closer space",
|
|
imageUrl: appState.currentUser?.photoUrl
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.closerPadding()
|
|
} else {
|
|
NavigationLink {
|
|
EditProfileView()
|
|
} label: {
|
|
SettingsProfileHeader(
|
|
initials: initials,
|
|
name: appState.currentUser?.displayName ?? "You",
|
|
detail: appState.currentUser?.email ?? "Edit your profile",
|
|
imageUrl: appState.currentUser?.photoUrl
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.closerPadding()
|
|
}
|
|
|
|
SettingsSectionCard(title: "For the two of you", accent: .closerSecondary) {
|
|
if let partner = appState.currentPartner {
|
|
SettingsLinkRow(
|
|
icon: "heart.fill",
|
|
title: "Connected with \(partner.displayName.isEmpty ? "Partner" : partner.displayName)",
|
|
subtitle: "Your shared Closer space is active",
|
|
tint: .closerPrimary,
|
|
imageUrl: partner.photoUrl,
|
|
showsChevron: false
|
|
)
|
|
} else {
|
|
Button(action: { isPairingActive = true }) {
|
|
SettingsLinkRow(
|
|
icon: "heart",
|
|
title: "Invite your partner",
|
|
subtitle: "Start answering together",
|
|
tint: .closerPrimary
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
SettingsDivider()
|
|
|
|
NavigationLink {
|
|
AnswerHistoryView()
|
|
} label: {
|
|
SettingsLinkRow(
|
|
icon: "checkmark.circle",
|
|
title: "Answer History",
|
|
subtitle: "Revisit the moments you have shared"
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.closerPadding()
|
|
|
|
SettingsSectionCard(title: "Your rhythm", accent: .closerPrimary) {
|
|
NavigationLink {
|
|
NavigationSettingsView()
|
|
} label: {
|
|
SettingsLinkRow(
|
|
icon: "bell.badge",
|
|
title: "Notifications",
|
|
subtitle: "Set gentle reminders that fit your day"
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.closerPadding()
|
|
|
|
SettingsSectionCard(title: "Premium", accent: .closerSecondary) {
|
|
Button {
|
|
showPaywall = true
|
|
} label: {
|
|
SettingsLinkRow(
|
|
icon: "heart.fill",
|
|
title: "Upgrade to Premium",
|
|
subtitle: "One subscription for both partners.",
|
|
tint: .closerPrimary
|
|
)
|
|
}
|
|
.disabled(isLoggedInAnonymously)
|
|
.opacity(isLoggedInAnonymously ? 0.55 : 1)
|
|
.buttonStyle(.plain)
|
|
}
|
|
.closerPadding()
|
|
|
|
SettingsSectionCard(title: "Privacy and safety", accent: .closerPrimary) {
|
|
NavigationLink {
|
|
DataExportView()
|
|
} label: {
|
|
SettingsLinkRow(
|
|
icon: "square.and.arrow.up",
|
|
title: "Export Data",
|
|
subtitle: "Download a copy of your Closer data"
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
SettingsDivider()
|
|
|
|
NavigationLink {
|
|
Text("Privacy Policy")
|
|
} label: {
|
|
SettingsLinkRow(
|
|
icon: "hand.raised",
|
|
title: "Privacy Policy",
|
|
subtitle: "How your data is handled"
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
SettingsDivider()
|
|
|
|
NavigationLink {
|
|
Text("Terms of Service")
|
|
} label: {
|
|
SettingsLinkRow(
|
|
icon: "doc.text",
|
|
title: "Terms of Service",
|
|
subtitle: "The agreement for using Closer"
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.closerPadding()
|
|
|
|
SettingsSectionCard(title: "Support", accent: .closerSecondary) {
|
|
NavigationLink {
|
|
HelpCenterView()
|
|
} label: {
|
|
SettingsLinkRow(
|
|
icon: "questionmark.circle",
|
|
title: "Help Center",
|
|
subtitle: "Answers for common questions"
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
SettingsDivider()
|
|
|
|
NavigationLink {
|
|
Text("Contact Us")
|
|
} label: {
|
|
SettingsLinkRow(
|
|
icon: "envelope",
|
|
title: "Contact Us",
|
|
subtitle: "Get help from the Closer team"
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
SettingsDivider()
|
|
|
|
SettingsVersionRow(version: appVersion)
|
|
}
|
|
.closerPadding()
|
|
|
|
SettingsSectionCard(title: "Account", accent: .closerDanger.opacity(0.5)) {
|
|
Button(role: .destructive, action: { showLogoutConfirm = true }) {
|
|
SettingsLinkRow(
|
|
icon: "rectangle.portrait.and.arrow.right",
|
|
title: "Sign Out",
|
|
subtitle: "Leave this device while keeping your data saved",
|
|
tint: .closerDanger,
|
|
isDestructive: true,
|
|
showsChevron: false
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
SettingsDivider()
|
|
|
|
Button(role: .destructive, action: { showDeleteConfirm = true }) {
|
|
SettingsLinkRow(
|
|
icon: "trash",
|
|
title: "Delete Account",
|
|
subtitle: "Permanently remove your Closer account",
|
|
tint: .closerDanger,
|
|
isDestructive: true,
|
|
showsChevron: false
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.closerPadding()
|
|
}
|
|
.padding(.top, CloserSpacing.md)
|
|
.padding(.bottom, CloserSpacing.xxl)
|
|
}
|
|
.background(Color.closerBackground)
|
|
.navigationTitle("Settings")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.fullScreenCover(isPresented: $showPaywall) {
|
|
PaywallView()
|
|
}
|
|
.fullScreenCover(isPresented: $isPairingActive) {
|
|
CreateInviteView()
|
|
}
|
|
.alert("Sign Out", isPresented: $showLogoutConfirm) {
|
|
Button("Cancel", role: .cancel) {}
|
|
Button("Sign Out", role: .destructive) {
|
|
Task { await AuthService.shared.signOut() }
|
|
}
|
|
} message: {
|
|
Text("Are you sure you want to sign out? Your data will be saved.")
|
|
}
|
|
.alert("Delete Account", isPresented: $showDeleteConfirm) {
|
|
Button("Cancel", role: .cancel) {}
|
|
Button("Delete", role: .destructive) {
|
|
Task { await deleteAccount() }
|
|
}
|
|
} message: {
|
|
Text("This will permanently delete your account and all data. This action cannot be undone.")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var initials: String {
|
|
let name = appState.currentUser?.displayName ?? "U"
|
|
let parts = name.split(separator: " ")
|
|
let initials = parts.prefix(2).compactMap { $0.first.map(String.init) }.joined()
|
|
return initials.isEmpty ? "U" : initials.uppercased()
|
|
}
|
|
|
|
private var isLoggedInAnonymously: Bool {
|
|
AuthService.shared.currentUser?.isAnonymous ?? true
|
|
}
|
|
|
|
private var appVersion: String {
|
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
|
}
|
|
|
|
private func deleteAccount() async {
|
|
do {
|
|
try await FirestoreService.shared.deleteUserCallable()
|
|
await AuthService.shared.signOut()
|
|
} catch {
|
|
// Error handled upstream
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Edit Profile
|
|
|
|
struct EditProfileView: View {
|
|
@State private var displayName = ""
|
|
@State private var bio = ""
|
|
@State private var isLoading = false
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section("Profile") {
|
|
TextField("Display Name", text: $displayName)
|
|
TextField("Bio (optional)", text: $bio, axis: .vertical)
|
|
.lineLimit(3)
|
|
}
|
|
|
|
Section {
|
|
Button(action: saveProfile) {
|
|
if isLoading {
|
|
ProgressView()
|
|
} else {
|
|
Text("Save")
|
|
}
|
|
}
|
|
.disabled(displayName.isEmpty || isLoading)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.background(Color.closerBackground)
|
|
.navigationTitle("Edit Profile")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.task {
|
|
displayName = AuthService.shared.currentUser?.displayName ?? ""
|
|
}
|
|
}
|
|
|
|
private func saveProfile() {
|
|
isLoading = true
|
|
Task {
|
|
try? await FirestoreService.shared.updateUserCallable(displayName: displayName, bio: bio.isEmpty ? nil : bio)
|
|
isLoading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Navigation Settings
|
|
|
|
struct NavigationSettingsView: View {
|
|
@AppStorage("dailyReminderEnabled") private var dailyReminders = true
|
|
@AppStorage("reminderHour") private var reminderHour = 20
|
|
@AppStorage("reminderMinute") private var reminderMinute = 0
|
|
@AppStorage("quietHoursEnabled") private var quietHoursEnabled = false
|
|
@AppStorage("quietHourStart") private var quietHourStart = 22
|
|
@AppStorage("quietHourEnd") private var quietHourEnd = 8
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section("Daily Question") {
|
|
Toggle("Daily Reminder", isOn: $dailyReminders)
|
|
|
|
if dailyReminders {
|
|
DatePicker("Reminder Time",
|
|
selection: Binding(
|
|
get: {
|
|
Calendar.current.date(from: DateComponents(hour: reminderHour, minute: reminderMinute)) ?? Date()
|
|
},
|
|
set: { date in
|
|
let comps = Calendar.current.dateComponents([.hour, .minute], from: date)
|
|
reminderHour = comps.hour ?? 20
|
|
reminderMinute = comps.minute ?? 0
|
|
}
|
|
),
|
|
displayedComponents: .hourAndMinute
|
|
)
|
|
}
|
|
}
|
|
|
|
Section("Quiet Hours") {
|
|
Toggle("Quiet Hours", isOn: $quietHoursEnabled)
|
|
|
|
if quietHoursEnabled {
|
|
HStack {
|
|
Text("From")
|
|
Spacer()
|
|
Picker("Start", selection: $quietHourStart) {
|
|
ForEach(0..<24) { hour in
|
|
Text("\(hour):00").tag(hour)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
}
|
|
|
|
HStack {
|
|
Text("To")
|
|
Spacer()
|
|
Picker("End", selection: $quietHourEnd) {
|
|
ForEach(0..<24) { hour in
|
|
Text("\(hour):00").tag(hour)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
}
|
|
}
|
|
}
|
|
|
|
Section("Partner Activity") {
|
|
Toggle("Partner Activity Alerts", isOn: .constant(true))
|
|
Toggle("When partner answers question", isOn: .constant(true))
|
|
Toggle("When partner sends gentle reminder", isOn: .constant(true))
|
|
Toggle("When new date match", isOn: .constant(true))
|
|
}
|
|
}
|
|
.background(Color.closerBackground)
|
|
.navigationTitle("Notification Settings")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
// MARK: - Data Export
|
|
|
|
struct DataExportView: View {
|
|
@State private var isExporting = false
|
|
@State private var exportComplete = false
|
|
@State private var showError = false
|
|
|
|
var body: some View {
|
|
VStack(spacing: CloserSpacing.xl) {
|
|
Image(systemName: "square.and.arrow.up")
|
|
.font(.system(size: 64))
|
|
.foregroundColor(.closerPrimary)
|
|
|
|
Text("Export Your Data")
|
|
.font(CloserFont.title2)
|
|
.foregroundColor(.closerText)
|
|
|
|
Text("Download a copy of all your Closer data including answers, memories, and preferences.")
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
|
|
if exportComplete {
|
|
HStack(spacing: CloserSpacing.sm) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.closerSuccess)
|
|
Text("Export completed — check your downloads")
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerSuccess)
|
|
}
|
|
.padding()
|
|
.background(Color.closerSuccess.opacity(0.1))
|
|
.cornerRadius(CloserRadius.medium)
|
|
}
|
|
|
|
Button(action: exportData) {
|
|
if isExporting {
|
|
ProgressView()
|
|
.tint(.white)
|
|
} else {
|
|
Text(exportComplete ? "Export Again" : "Export My Data")
|
|
}
|
|
}
|
|
.buttonStyle(PrimaryButtonStyle(isDisabled: isExporting))
|
|
.disabled(isExporting)
|
|
.closerPadding()
|
|
|
|
if exportComplete {
|
|
Button("Done") {
|
|
exportComplete = false
|
|
}
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerPrimary)
|
|
}
|
|
}
|
|
.closerPadding()
|
|
.background(Color.closerBackground)
|
|
.navigationTitle("Export Data")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.alert("Export Failed", isPresented: $showError) {
|
|
Button("OK", role: .cancel) {}
|
|
} message: {
|
|
Text("Unable to export your data. Please try again later.")
|
|
}
|
|
}
|
|
|
|
private func exportData() {
|
|
isExporting = true
|
|
Task {
|
|
do {
|
|
try await FirestoreService.shared.exportDataCallable()
|
|
await MainActor.run {
|
|
isExporting = false
|
|
exportComplete = true
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
isExporting = false
|
|
showError = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Help Center
|
|
|
|
struct HelpCenterView: View {
|
|
var body: some View {
|
|
List {
|
|
Section("FAQs") {
|
|
NavigationLink("How does the daily question work?") {
|
|
DailyQuestionHelpView()
|
|
}
|
|
NavigationLink("What happens if I sign out?") {
|
|
SignOutHelpView()
|
|
}
|
|
NavigationLink("How does pairing work?") {
|
|
PairingHelpView()
|
|
}
|
|
NavigationLink("What is Premium?") {
|
|
PremiumHelpView()
|
|
}
|
|
NavigationLink("How does encryption work?") {
|
|
EncryptionHelpView()
|
|
}
|
|
}
|
|
|
|
Section("Troubleshooting") {
|
|
NavigationLink("Notifications not working") {
|
|
GenericHelpView(title: "Notifications", body: "Make sure push notifications are enabled in your device Settings > Closer. If you've denied permission, go to Settings > Closer > Notifications and enable them.")
|
|
}
|
|
NavigationLink("Can't pair with partner") {
|
|
GenericHelpView(title: "Pairing Issues", body: "Confirm your partner has created an account. Check that your invite code is entered correctly (hypens are optional). If the code expired, generate a new one from Settings > Connection.")
|
|
}
|
|
NavigationLink("Restore purchases") {
|
|
GenericHelpView(title: "Restore Purchases", body: "Open Settings and tap 'Upgrade to Premium', then tap 'Restore Purchases'. If your subscription was purchased with a different Apple ID, sign in to that Apple ID and try again.")
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.background(Color.closerBackground)
|
|
.navigationTitle("Help Center")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
// MARK: - Help Detail Views
|
|
|
|
struct DailyQuestionHelpView: View {
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: CloserSpacing.md) {
|
|
Text("Every day, you and your partner receive the same question. Answer independently — your answer is private until both of you respond. Once both answers are in, you can reveal each other's answers together. It's a simple way to deepen your connection one day at a time.")
|
|
.font(CloserFont.body)
|
|
.foregroundColor(.closerText)
|
|
|
|
Text("Tips:")
|
|
.font(CloserFont.headline)
|
|
.foregroundColor(.closerText)
|
|
.padding(.top)
|
|
|
|
BulletPoint("Be honest — your partner sees only what you share")
|
|
BulletPoint("Set a daily reminder in Settings > Notifications")
|
|
BulletPoint("Send a gentle reminder if your partner hasn't answered yet")
|
|
BulletPoint("Past answers live in Answer History")
|
|
}
|
|
.closerPadding()
|
|
}
|
|
.background(Color.closerBackground)
|
|
.navigationTitle("Daily Questions")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
struct SignOutHelpView: View {
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: CloserSpacing.md) {
|
|
Text("Your data is saved securely in the cloud. When you sign back in, everything will be right where you left it — your answers, memories, and connection will all be restored.")
|
|
.font(CloserFont.body)
|
|
.foregroundColor(.closerText)
|
|
|
|
Text("Note:")
|
|
.font(CloserFont.headline)
|
|
.foregroundColor(.closerText)
|
|
.padding(.top)
|
|
|
|
BulletPoint("Your partner can still access shared data")
|
|
BulletPoint("Push notifications may stop until you sign back in")
|
|
BulletPoint("If you're the only one in the couple, you may need to re-invite your partner")
|
|
}
|
|
.closerPadding()
|
|
}
|
|
.background(Color.closerBackground)
|
|
.navigationTitle("Sign Out")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
struct PairingHelpView: View {
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: CloserSpacing.md) {
|
|
Text("After creating your account, go to Settings > Connection to generate a unique 6-character invite code. Share this code with your partner — they enter it on their end to connect your accounts.")
|
|
.font(CloserFont.body)
|
|
.foregroundColor(.closerText)
|
|
|
|
BulletPoint("Codes expire after a set time — generate a new one if needed")
|
|
BulletPoint("Each account can only be in one couple at a time")
|
|
BulletPoint("Both of you need a Closer account first")
|
|
}
|
|
.closerPadding()
|
|
}
|
|
.background(Color.closerBackground)
|
|
.navigationTitle("Pairing")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
struct PremiumHelpView: View {
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: CloserSpacing.md) {
|
|
Text("Closer Premium unlocks deeper connection tools:")
|
|
.font(CloserFont.body)
|
|
.foregroundColor(.closerText)
|
|
|
|
FeatureBullet("sparkles", "Desire Sync", "Align your desires and dreams")
|
|
FeatureBullet("mountain.2.fill", "Connection Challenges", "Multi-day programs")
|
|
FeatureBullet("clock.fill", "Memory Lane", "Time capsules for your relationship")
|
|
FeatureBullet("questionmark.bubble.fill", "Unlimited Packs", "All premium question packs")
|
|
FeatureBullet("chart.bar.fill", "Advanced Insights", "Deeper relationship analytics")
|
|
FeatureBullet("heart.fill", "Priority Support", "Faster customer support")
|
|
|
|
Text("\n$4.99/month. Cancel anytime through your Apple ID subscription settings.")
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
}
|
|
.closerPadding()
|
|
}
|
|
.background(Color.closerBackground)
|
|
.navigationTitle("Premium")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
struct EncryptionHelpView: View {
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: CloserSpacing.md) {
|
|
Text("Your answers and private data are encrypted end-to-end using industry-standard encryption. Only you and your partner have the keys to read your shared content — not even Closer's servers can access it.")
|
|
.font(CloserFont.body)
|
|
.foregroundColor(.closerText)
|
|
|
|
BulletPoint("Encryption keys stay on your device")
|
|
BulletPoint("Data is encrypted before it leaves your phone")
|
|
BulletPoint("Your partner's device decrypts it on arrival")
|
|
BulletPoint("Your encryption key is backed up securely for recovery")
|
|
}
|
|
.closerPadding()
|
|
}
|
|
.background(Color.closerBackground)
|
|
.navigationTitle("Encryption")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
struct GenericHelpView: View {
|
|
let title: String
|
|
let body: String
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: CloserSpacing.md) {
|
|
Text(body)
|
|
.font(CloserFont.body)
|
|
.foregroundColor(.closerText)
|
|
}
|
|
.closerPadding()
|
|
}
|
|
.background(Color.closerBackground)
|
|
.navigationTitle(title)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
// MARK: - Paywall
|
|
|
|
struct PaywallView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var isPurchasing = false
|
|
@State private var showError = false
|
|
@State private var showSuccess = false
|
|
@State private var selectedPlan: Plan = .monthly
|
|
|
|
enum Plan: String, CaseIterable {
|
|
case monthly = "monthly"
|
|
case yearly = "yearly"
|
|
|
|
var price: String {
|
|
switch self {
|
|
case .monthly: return "$4.99"
|
|
case .yearly: return "$29.99"
|
|
}
|
|
}
|
|
|
|
var period: String {
|
|
switch self {
|
|
case .monthly: return "/month"
|
|
case .yearly: return "/year"
|
|
}
|
|
}
|
|
|
|
var savings: String? {
|
|
switch self {
|
|
case .monthly: return nil
|
|
case .yearly: return "Save 50%"
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: CloserSpacing.xxl) {
|
|
VStack(spacing: CloserSpacing.md) {
|
|
CloserIllustrationView(imageName: "illustration-couple-paywall", size: 184)
|
|
|
|
Text("Go deeper together")
|
|
.font(CloserFont.title1)
|
|
.foregroundColor(.closerText)
|
|
.multilineTextAlignment(.center)
|
|
|
|
Text("Unlock everything Closer has built for couples.")
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(.top, CloserSpacing.xxl)
|
|
|
|
// Features
|
|
VStack(alignment: .leading, spacing: CloserSpacing.lg) {
|
|
PremiumFeatureRow(icon: "sparkles", title: "Desire Sync", description: "Align your desires and dreams")
|
|
PremiumFeatureRow(icon: "mountain.2.fill", title: "Connection Challenges", description: "Multi-day programs to strengthen your bond")
|
|
PremiumFeatureRow(icon: "clock.fill", title: "Memory Lane", description: "Create and unlock time capsules")
|
|
PremiumFeatureRow(icon: "questionmark.bubble.fill", title: "Unlimited Packs", description: "Access all premium question packs")
|
|
PremiumFeatureRow(icon: "chart.bar.fill", title: "Advanced Insights", description: "Deeper relationship analytics and trends")
|
|
PremiumFeatureRow(icon: "heart.fill", title: "Premium Support", description: "Priority customer support")
|
|
}
|
|
.padding()
|
|
.closerCard()
|
|
.closerPadding()
|
|
|
|
// Plan selector
|
|
VStack(spacing: CloserSpacing.md) {
|
|
ForEach(Plan.allCases, id: \.self) { plan in
|
|
Button(action: { selectedPlan = plan }) {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 6) {
|
|
Text("\(plan.price)\(plan.period)")
|
|
.font(CloserFont.title3)
|
|
.foregroundColor(.closerText)
|
|
if let savings = plan.savings {
|
|
Text(savings)
|
|
.font(CloserFont.caption)
|
|
.foregroundColor(.closerSuccess)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.closerSuccess.opacity(0.1))
|
|
.cornerRadius(CloserRadius.full)
|
|
}
|
|
}
|
|
if plan == .yearly {
|
|
Text("Billed annually — cancel anytime")
|
|
.font(CloserFont.caption)
|
|
.foregroundColor(.closerTextSecondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
if selectedPlan == plan {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.closerPrimary)
|
|
} else {
|
|
Circle()
|
|
.stroke(Color.closerDivider)
|
|
.frame(width: 22, height: 22)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(selectedPlan == plan ? Color.closerPrimary.opacity(0.05) : Color.closerSurface)
|
|
.cornerRadius(CloserRadius.medium)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: CloserRadius.medium)
|
|
.stroke(selectedPlan == plan ? Color.closerPrimary : Color.closerDivider)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.closerPadding()
|
|
|
|
if showSuccess {
|
|
HStack(spacing: CloserSpacing.sm) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.closerSuccess)
|
|
Text("Welcome to Premium!")
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerSuccess)
|
|
}
|
|
.padding()
|
|
.background(Color.closerSuccess.opacity(0.1))
|
|
.cornerRadius(CloserRadius.medium)
|
|
}
|
|
|
|
// Subscribe button
|
|
Button(action: purchase) {
|
|
if isPurchasing {
|
|
ProgressView()
|
|
.tint(.white)
|
|
} else {
|
|
Text("Try Premium Free")
|
|
}
|
|
}
|
|
.buttonStyle(PrimaryButtonStyle(isDisabled: isPurchasing))
|
|
.disabled(isPurchasing)
|
|
.closerPadding()
|
|
|
|
// Restore
|
|
Button("Restore Purchases") {
|
|
Task {
|
|
do {
|
|
let _ = try await Purchases.shared.restorePurchases()
|
|
showSuccess = true
|
|
} catch {
|
|
showError = true
|
|
}
|
|
}
|
|
}
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerPrimary)
|
|
|
|
// Terms
|
|
Text("Subscription automatically renews unless cancelled at least 24 hours before the end of the current period. Manage in your Apple ID Settings.")
|
|
.font(CloserFont.caption2)
|
|
.foregroundColor(.closerTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.closerPadding()
|
|
}
|
|
}
|
|
.background(Color.closerBackground)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Close") { dismiss() }
|
|
}
|
|
}
|
|
.alert("Purchase Error", isPresented: $showError) {
|
|
Button("OK", role: .cancel) {}
|
|
} message: {
|
|
Text("Unable to process your purchase. Please try again later.")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func purchase() {
|
|
isPurchasing = true
|
|
Task {
|
|
do {
|
|
let _ = try await BillingService.shared.purchase()
|
|
await MainActor.run {
|
|
isPurchasing = false
|
|
showSuccess = true
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
isPurchasing = false
|
|
showError = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Settings Row Helpers
|
|
|
|
struct SettingsSectionCard<Content: View>: View {
|
|
let title: String
|
|
let accent: Color
|
|
let content: Content
|
|
|
|
init(title: String, accent: Color, @ViewBuilder content: () -> Content) {
|
|
self.title = title
|
|
self.accent = accent
|
|
self.content = content()
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: CloserSpacing.sm) {
|
|
Text(title)
|
|
.font(CloserFont.caption)
|
|
.foregroundColor(.closerTextSecondary)
|
|
.textCase(.uppercase)
|
|
|
|
VStack(spacing: 0) {
|
|
content
|
|
}
|
|
.background(Color.closerSurface.opacity(0.72))
|
|
.clipShape(RoundedRectangle(cornerRadius: CloserRadius.large, style: .continuous))
|
|
}
|
|
.padding(CloserSpacing.md)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [
|
|
Color.closerSurface,
|
|
accent.opacity(0.18)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: CloserRadius.xlarge, style: .continuous))
|
|
.closerShadow(level: .small)
|
|
}
|
|
}
|
|
|
|
struct SettingsProfileHeader: View {
|
|
let initials: String
|
|
let name: String
|
|
let detail: String
|
|
var imageUrl: String? = nil
|
|
|
|
var body: some View {
|
|
VStack(spacing: CloserSpacing.sm) {
|
|
SettingsAvatarView(
|
|
imageUrl: imageUrl,
|
|
fallbackIcon: nil,
|
|
fallbackText: initials,
|
|
size: 72,
|
|
tint: .closerPrimary
|
|
)
|
|
|
|
Text(name)
|
|
.font(CloserFont.title3)
|
|
.foregroundColor(.closerText)
|
|
|
|
Text(detail)
|
|
.font(CloserFont.callout)
|
|
.foregroundColor(.closerTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(CloserSpacing.lg)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [
|
|
Color.closerSurface,
|
|
Color.closerSecondary.opacity(0.14)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: CloserRadius.xlarge, style: .continuous))
|
|
.closerShadow(level: .small)
|
|
}
|
|
}
|
|
|
|
struct SettingsLinkRow: View {
|
|
let icon: String
|
|
let title: String
|
|
let subtitle: String
|
|
var tint: Color = .closerTextSecondary
|
|
var imageUrl: String? = nil
|
|
var isDestructive: Bool = false
|
|
var showsChevron: Bool = true
|
|
|
|
var body: some View {
|
|
HStack(spacing: CloserSpacing.md) {
|
|
if let imageUrl, !imageUrl.isEmpty {
|
|
SettingsAvatarView(
|
|
imageUrl: imageUrl,
|
|
fallbackIcon: icon,
|
|
fallbackText: nil,
|
|
size: 40,
|
|
tint: tint
|
|
)
|
|
} else {
|
|
SettingsIconTile(icon: icon, tint: tint, isDestructive: isDestructive)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(title)
|
|
.font(CloserFont.body)
|
|
.foregroundColor(isDestructive ? .closerDanger : .closerText)
|
|
.lineLimit(1)
|
|
Text(subtitle)
|
|
.font(CloserFont.caption)
|
|
.foregroundColor(.closerTextSecondary)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
Spacer(minLength: CloserSpacing.sm)
|
|
|
|
if showsChevron {
|
|
Image(systemName: "chevron.right")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundColor(.closerTextSecondary.opacity(0.72))
|
|
}
|
|
}
|
|
.padding(.horizontal, CloserSpacing.md)
|
|
.padding(.vertical, CloserSpacing.sm)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
struct SettingsIconTile: View {
|
|
let icon: String
|
|
let tint: Color
|
|
var isDestructive: Bool = false
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.fill(tint.opacity(isDestructive ? 0.12 : 0.14))
|
|
Image(systemName: icon)
|
|
.font(.headline)
|
|
.foregroundColor(tint)
|
|
}
|
|
.frame(width: 40, height: 40)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
struct SettingsDivider: View {
|
|
var body: some View {
|
|
Rectangle()
|
|
.fill(Color.closerDivider.opacity(0.8))
|
|
.frame(height: 1)
|
|
.padding(.leading, 64)
|
|
.padding(.trailing, CloserSpacing.md)
|
|
}
|
|
}
|
|
|
|
struct SettingsVersionRow: View {
|
|
let version: String
|
|
|
|
var body: some View {
|
|
SettingsLinkRow(
|
|
icon: "info.circle",
|
|
title: "Version",
|
|
subtitle: version,
|
|
showsChevron: false
|
|
)
|
|
}
|
|
}
|
|
|
|
struct SettingsLinkLabel: View {
|
|
let icon: String
|
|
let title: String
|
|
let subtitle: String
|
|
var tint: Color = .closerTextSecondary
|
|
var imageUrl: String? = nil
|
|
|
|
var body: some View {
|
|
HStack(spacing: CloserSpacing.md) {
|
|
SettingsAvatarView(
|
|
imageUrl: imageUrl,
|
|
fallbackIcon: icon,
|
|
fallbackText: nil,
|
|
size: 34,
|
|
tint: tint
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(CloserFont.body)
|
|
.foregroundColor(.closerText)
|
|
Text(subtitle)
|
|
.font(CloserFont.caption)
|
|
.foregroundColor(.closerTextSecondary)
|
|
}
|
|
}
|
|
.padding(.vertical, CloserSpacing.xs)
|
|
}
|
|
}
|
|
|
|
struct SettingsAvatarView: View {
|
|
let imageUrl: String?
|
|
let fallbackIcon: String?
|
|
let fallbackText: String?
|
|
let size: CGFloat
|
|
let tint: Color
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Circle()
|
|
.fill(tint.opacity(0.16))
|
|
|
|
if let urlString = imageUrl, !urlString.isEmpty, let url = URL(string: urlString) {
|
|
AsyncImage(url: url) { phase in
|
|
switch phase {
|
|
case .success(let image):
|
|
image
|
|
.resizable()
|
|
.scaledToFill()
|
|
default:
|
|
fallback
|
|
}
|
|
}
|
|
} else {
|
|
fallback
|
|
}
|
|
}
|
|
.frame(width: size, height: size)
|
|
.clipShape(Circle())
|
|
.overlay(Circle().stroke(Color.closerSurface, lineWidth: 1.5))
|
|
.accessibilityHidden(true)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var fallback: some View {
|
|
if let fallbackText {
|
|
Text(fallbackText)
|
|
.font(size >= 60 ? CloserFont.title1 : CloserFont.caption)
|
|
.foregroundColor(tint)
|
|
} else if let fallbackIcon {
|
|
Image(systemName: fallbackIcon)
|
|
.font(size >= 44 ? .title3 : .headline)
|
|
.foregroundColor(tint)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Premium Settings CTA
|
|
|
|
struct PremiumSettingsCTA: View {
|
|
var body: some View {
|
|
HStack(spacing: CloserSpacing.md) {
|
|
Image("illustration-couple-subscription")
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: 68, height: 68)
|
|
.clipShape(RoundedRectangle(cornerRadius: CloserRadius.large, style: .continuous))
|
|
.accessibilityHidden(true)
|
|
|
|
VStack(alignment: .leading, spacing: CloserSpacing.xs) {
|
|
Text("Upgrade to Premium")
|
|
.font(CloserFont.headline)
|
|
.foregroundColor(.closerText)
|
|
Text("One subscription for both partners.")
|
|
.font(CloserFont.caption)
|
|
.foregroundColor(.closerTextSecondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundColor(.closerTextSecondary)
|
|
}
|
|
.padding(.vertical, CloserSpacing.xs)
|
|
}
|
|
}
|
|
|
|
// MARK: - Premium Feature Row
|
|
|
|
struct PremiumFeatureRow: View {
|
|
let icon: String
|
|
let title: String
|
|
let description: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: CloserSpacing.md) {
|
|
Image(systemName: icon)
|
|
.font(.title3)
|
|
.foregroundColor(.closerGold)
|
|
.frame(width: 32)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(CloserFont.body)
|
|
.foregroundColor(.closerText)
|
|
Text(description)
|
|
.font(CloserFont.caption)
|
|
.foregroundColor(.closerTextSecondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helper Views
|
|
|
|
struct BulletPoint: View {
|
|
let text: String
|
|
|
|
init(_ text: String) {
|
|
self.text = text
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: CloserSpacing.sm) {
|
|
Text("\u{2022}")
|
|
.foregroundColor(.closerPrimary)
|
|
Text(text)
|
|
.font(CloserFont.body)
|
|
.foregroundColor(.closerText)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct FeatureBullet: View {
|
|
let icon: String
|
|
let title: String
|
|
let description: String
|
|
|
|
init(_ icon: String, _ title: String, _ description: String) {
|
|
self.icon = icon
|
|
self.title = title
|
|
self.description = description
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: CloserSpacing.md) {
|
|
Image(systemName: icon)
|
|
.font(.callout)
|
|
.foregroundColor(.closerGold)
|
|
.frame(width: 24)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(CloserFont.body)
|
|
.foregroundColor(.closerText)
|
|
Text(description)
|
|
.font(CloserFont.caption)
|
|
.foregroundColor(.closerTextSecondary)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|