feat: settings screen + iOS settings views

This commit is contained in:
null 2026-06-22 18:14:55 -05:00
parent b90b9bca77
commit 42706b4dc6
2 changed files with 381 additions and 155 deletions

View File

@ -36,8 +36,8 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -117,14 +117,23 @@ fun SettingsSubpage(
fun SettingsSection(
title: String? = null,
modifier: Modifier = Modifier,
accent: Color = Color(0xFFF7C8E4),
content: @Composable () -> Unit
) {
Column(
Card(
modifier = modifier
.fillMaxWidth(),
shape = RoundedCornerShape(22.dp),
colors = CardDefaults.cardColors(containerColor = accent.copy(alpha = 0.16f)),
elevation = CardDefaults.cardElevation(defaultElevation = 5.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(SettingsCard, RoundedCornerShape(16.dp))
.padding(1.dp)
.background(SettingsCard.copy(alpha = 0.92f), RoundedCornerShape(21.dp))
.padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
title?.let {
Text(
@ -136,11 +145,12 @@ fun SettingsSection(
}
content()
}
}
}
@Composable
fun SettingsSectionDivider() {
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
}
// ====================
@ -287,18 +297,28 @@ fun SettingsScreen(
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = "Manage your space, reminders, privacy, and account.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted,
modifier = Modifier.padding(horizontal = 4.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
// Profile card — editable identity (currently local-only)
Card(
onClick = { onNavigate(AppRoute.ACCOUNT) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = SettingsCard)
shape = RoundedCornerShape(22.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF8FC).copy(alpha = 0.96f)),
elevation = CardDefaults.cardElevation(defaultElevation = 5.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
modifier = Modifier.padding(18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
@ -345,13 +365,14 @@ fun SettingsScreen(
)
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = SettingsCard)
shape = RoundedCornerShape(22.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFF8F0FF).copy(alpha = 0.96f)),
elevation = CardDefaults.cardElevation(defaultElevation = 5.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
.padding(18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
@ -399,9 +420,7 @@ fun SettingsScreen(
}
}
Spacer(Modifier.height(4.dp))
SettingsSection(title = "For the two of you") {
SettingsSection(title = "For the two of you", accent = Color(0xFFF7C8E4)) {
SettingsRow(
icon = Icons.Filled.Done,
label = "Answer History",
@ -417,7 +436,7 @@ fun SettingsScreen(
)
}
SettingsSection(title = "Your rhythm") {
SettingsSection(title = "Your rhythm", accent = Color(0xFFD9B8FF)) {
SettingsRow(
icon = Icons.Filled.Notifications,
label = "Notifications",
@ -433,7 +452,7 @@ fun SettingsScreen(
)
}
SettingsSection(title = "Premium") {
SettingsSection(title = "Premium", accent = Color(0xFFE7A2D1)) {
SettingsRow(
icon = Icons.Filled.Favorite,
label = "Subscription",
@ -443,7 +462,7 @@ fun SettingsScreen(
)
}
SettingsSection(title = "Privacy and safety") {
SettingsSection(title = "Privacy and safety", accent = Color(0xFFD9B8FF)) {
SettingsRow(
icon = Icons.Filled.Lock,
label = "Security",
@ -459,7 +478,7 @@ fun SettingsScreen(
)
}
SettingsSection(title = "Account") {
SettingsSection(title = "Account", accent = Color(0xFFFFD9E8)) {
SettingsRow(
icon = Icons.Filled.Warning,
label = "Delete account",
@ -540,7 +559,20 @@ private fun SettingsRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(icon, contentDescription = null, tint = tint)
androidx.compose.foundation.layout.Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(14.dp))
.background(tint.copy(alpha = if (tint == SettingsDanger) 0.12f else 0.14f)),
contentAlignment = Alignment.Center
) {
Icon(
icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(20.dp)
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)

View File

@ -13,8 +13,14 @@ struct SettingsView: View {
var body: some View {
NavigationStack {
List {
Section {
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()
@ -26,6 +32,8 @@ struct SettingsView: View {
imageUrl: appState.currentUser?.photoUrl
)
}
.buttonStyle(.plain)
.closerPadding()
} else {
NavigationLink {
EditProfileView()
@ -37,21 +45,23 @@ struct SettingsView: View {
imageUrl: appState.currentUser?.photoUrl
)
}
}
.buttonStyle(.plain)
.closerPadding()
}
Section("For the two of you") {
SettingsSectionCard(title: "For the two of you", accent: .closerSecondary) {
if let partner = appState.currentPartner {
SettingsLinkLabel(
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
imageUrl: partner.photoUrl,
showsChevron: false
)
} else {
Button(action: { isPairingActive = true }) {
SettingsLinkLabel(
SettingsLinkRow(
icon: "heart",
title: "Invite your partner",
subtitle: "Start answering together",
@ -61,115 +71,158 @@ struct SettingsView: View {
.buttonStyle(.plain)
}
SettingsDivider()
NavigationLink {
AnswerHistoryView()
} label: {
SettingsLinkLabel(
SettingsLinkRow(
icon: "checkmark.circle",
title: "Answer History",
subtitle: "Revisit the moments you have shared"
)
}
.buttonStyle(.plain)
}
.closerPadding()
Section("Your rhythm") {
SettingsSectionCard(title: "Your rhythm", accent: .closerPrimary) {
NavigationLink {
NavigationSettingsView()
} label: {
SettingsLinkLabel(
SettingsLinkRow(
icon: "bell.badge",
title: "Notifications",
subtitle: "Set gentle reminders that fit your day"
)
}
.buttonStyle(.plain)
}
.closerPadding()
Section("Premium") {
SettingsSectionCard(title: "Premium", accent: .closerSecondary) {
Button {
showPaywall = true
} label: {
PremiumSettingsCTA()
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()
Section("Privacy and safety") {
SettingsSectionCard(title: "Privacy and safety", accent: .closerPrimary) {
NavigationLink {
DataExportView()
} label: {
SettingsLinkLabel(
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: {
SettingsLinkLabel(
SettingsLinkRow(
icon: "hand.raised",
title: "Privacy Policy",
subtitle: "How your data is handled"
)
}
.buttonStyle(.plain)
SettingsDivider()
NavigationLink {
Text("Terms of Service")
} label: {
SettingsLinkLabel(
SettingsLinkRow(
icon: "doc.text",
title: "Terms of Service",
subtitle: "The agreement for using Closer"
)
}
.buttonStyle(.plain)
}
.closerPadding()
Section("Support") {
SettingsSectionCard(title: "Support", accent: .closerSecondary) {
NavigationLink {
HelpCenterView()
} label: {
SettingsLinkLabel(
SettingsLinkRow(
icon: "questionmark.circle",
title: "Help Center",
subtitle: "Answers for common questions"
)
}
.buttonStyle(.plain)
SettingsDivider()
NavigationLink {
Text("Contact Us")
} label: {
SettingsLinkLabel(
SettingsLinkRow(
icon: "envelope",
title: "Contact Us",
subtitle: "Get help from the Closer team"
)
}
.buttonStyle(.plain)
HStack {
Label("Version", systemImage: "info.circle")
Spacer()
Text(appVersion)
.font(CloserFont.footnote)
.foregroundColor(.closerTextSecondary)
}
}
SettingsDivider()
Section("Account") {
SettingsVersionRow(version: appVersion)
}
.closerPadding()
SettingsSectionCard(title: "Account", accent: .closerDanger.opacity(0.5)) {
Button(role: .destructive, action: { showLogoutConfirm = true }) {
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
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 }) {
Label("Delete Account", systemImage: "trash")
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)
}
.listStyle(.insetGrouped)
.background(Color.closerBackground)
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.navigationBarTitleDisplayMode(.large)
.fullScreenCover(isPresented: $showPaywall) {
PaywallView()
}
@ -803,6 +856,46 @@ struct PaywallView: View {
// 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.white.opacity(0.58))
.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
@ -829,7 +922,108 @@ struct SettingsProfileHeader: View {
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
.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
)
}
}