feat: settings screen + iOS settings views
This commit is contained in:
parent
b90b9bca77
commit
42706b4dc6
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue