Closer/iphone/Closer/Settings/SettingsViews.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)
}
}