diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4baa4df1..0614cbff 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/couplesconnect/app/core/navigation/AppNavigation.kt b/app/src/main/java/com/couplesconnect/app/core/navigation/AppNavigation.kt
index 722b654a..71988709 100644
--- a/app/src/main/java/com/couplesconnect/app/core/navigation/AppNavigation.kt
+++ b/app/src/main/java/com/couplesconnect/app/core/navigation/AppNavigation.kt
@@ -41,8 +41,10 @@ import com.couplesconnect.app.ui.questions.QuestionComposerScreen
import com.couplesconnect.app.ui.questions.QuestionPackLibraryScreen
import com.couplesconnect.app.ui.questions.QuestionThreadScreen
import com.couplesconnect.app.ui.settings.AccountScreen
+import com.couplesconnect.app.ui.settings.DeleteAccountScreen
import com.couplesconnect.app.ui.settings.NotificationSettingsScreen
import com.couplesconnect.app.ui.settings.PrivacyScreen
+import com.couplesconnect.app.ui.settings.RelationshipSettingsScreen
import com.couplesconnect.app.ui.settings.SettingsScreen
import com.couplesconnect.app.ui.settings.SubscriptionScreen
import com.couplesconnect.app.ui.wheel.CategoryPickerScreen
@@ -242,6 +244,12 @@ fun AppNavigation(
composable(route = AppRoute.SUBSCRIPTION) {
SubscriptionScreen(onNavigate = navController::navigate)
}
+ composable(route = AppRoute.RELATIONSHIP_SETTINGS) {
+ RelationshipSettingsScreen(onNavigate = navController::navigate)
+ }
+ composable(route = AppRoute.DELETE_ACCOUNT) {
+ DeleteAccountScreen(onNavigate = navController::navigate)
+ }
}
}
}
diff --git a/app/src/main/java/com/couplesconnect/app/core/navigation/AppRoute.kt b/app/src/main/java/com/couplesconnect/app/core/navigation/AppRoute.kt
index d92e149d..d6f638e3 100644
--- a/app/src/main/java/com/couplesconnect/app/core/navigation/AppRoute.kt
+++ b/app/src/main/java/com/couplesconnect/app/core/navigation/AppRoute.kt
@@ -30,6 +30,8 @@ object AppRoute {
const val NOTIFICATIONS = "notifications"
const val PRIVACY = "privacy"
const val SUBSCRIPTION = "subscription"
+ const val RELATIONSHIP_SETTINGS = "relationship_settings"
+ const val DELETE_ACCOUNT = "delete_account"
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
const val QUESTION_THREAD =
@@ -69,7 +71,9 @@ object AppRoute {
Definition(ACCOUNT, "Account", "settings"),
Definition(NOTIFICATIONS, "Notifications", "settings"),
Definition(PRIVACY, "Privacy", "settings"),
- Definition(SUBSCRIPTION, "Subscription", "settings")
+ Definition(SUBSCRIPTION, "Subscription", "settings"),
+ Definition(RELATIONSHIP_SETTINGS, "Relationship Settings", "settings"),
+ Definition(DELETE_ACCOUNT, "Delete Account", "settings")
)
fun answerReveal(questionId: String): String = "answer_reveal/${questionId.asRouteArg()}"
diff --git a/app/src/main/java/com/couplesconnect/app/core/notifications/AppMessagingService.kt b/app/src/main/java/com/couplesconnect/app/core/notifications/AppMessagingService.kt
new file mode 100644
index 00000000..5218d827
--- /dev/null
+++ b/app/src/main/java/com/couplesconnect/app/core/notifications/AppMessagingService.kt
@@ -0,0 +1,58 @@
+package com.couplesconnect.app.core.notifications
+
+import com.couplesconnect.app.domain.repository.AuthRepository
+import com.couplesconnect.app.domain.repository.UserRepository
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class AppMessagingService : FirebaseMessagingService() {
+
+ @Inject lateinit var authRepository: AuthRepository
+ @Inject lateinit var userRepository: UserRepository
+
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun onCreate() {
+ super.onCreate()
+ NotificationHelper.createChannels(this)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ serviceScope.cancel()
+ }
+
+ override fun onNewToken(token: String) {
+ val uid = authRepository.currentUserId ?: return
+ serviceScope.launch {
+ runCatching { userRepository.storeFcmToken(uid, token) }
+ }
+ }
+
+ override fun onMessageReceived(message: RemoteMessage) {
+ val title = message.notification?.title ?: message.data["title"] ?: return
+ val body = message.notification?.body ?: message.data["body"] ?: return
+ val type = message.data["type"] ?: "general"
+
+ val channelId = when (type) {
+ "partner_answered" -> NotificationHelper.CHANNEL_PARTNER
+ else -> NotificationHelper.CHANNEL_REMINDERS
+ }
+
+ NotificationHelper.show(
+ context = this,
+ id = System.currentTimeMillis().toInt(),
+ channelId = channelId,
+ title = title,
+ body = body
+ )
+ }
+}
diff --git a/app/src/main/java/com/couplesconnect/app/core/notifications/NotificationHelper.kt b/app/src/main/java/com/couplesconnect/app/core/notifications/NotificationHelper.kt
new file mode 100644
index 00000000..f6214a80
--- /dev/null
+++ b/app/src/main/java/com/couplesconnect/app/core/notifications/NotificationHelper.kt
@@ -0,0 +1,60 @@
+package com.couplesconnect.app.core.notifications
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.couplesconnect.app.MainActivity
+import com.couplesconnect.app.R
+
+object NotificationHelper {
+
+ const val CHANNEL_REMINDERS = "reminders"
+ const val CHANNEL_PARTNER = "partner_activity"
+
+ fun createChannels(context: Context) {
+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ nm.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_REMINDERS,
+ "Daily reminders",
+ NotificationManager.IMPORTANCE_DEFAULT
+ ).apply {
+ description = "Daily question and streak reminders"
+ }
+ )
+ nm.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_PARTNER,
+ "Partner activity",
+ NotificationManager.IMPORTANCE_HIGH
+ ).apply {
+ description = "When your partner answers a question"
+ }
+ )
+ }
+
+ fun show(context: Context, id: Int, channelId: String, title: String, body: String) {
+ val intent = Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+ val pending = PendingIntent.getActivity(
+ context, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ val notification = NotificationCompat.Builder(context, channelId)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle(title)
+ .setContentText(body)
+ .setAutoCancel(true)
+ .setContentIntent(pending)
+ .build()
+
+ if (NotificationManagerCompat.from(context).areNotificationsEnabled()) {
+ NotificationManagerCompat.from(context).notify(id, notification)
+ }
+ }
+}
diff --git a/app/src/main/java/com/couplesconnect/app/data/remote/FirebaseAuthDataSource.kt b/app/src/main/java/com/couplesconnect/app/data/remote/FirebaseAuthDataSource.kt
index 92178e44..82146cc7 100644
--- a/app/src/main/java/com/couplesconnect/app/data/remote/FirebaseAuthDataSource.kt
+++ b/app/src/main/java/com/couplesconnect/app/data/remote/FirebaseAuthDataSource.kt
@@ -65,4 +65,13 @@ class FirebaseAuthDataSource @Inject constructor() {
}
fun signOut() = auth.signOut()
+
+ suspend fun deleteAccount(): Unit =
+ suspendCancellableCoroutine { cont ->
+ auth.currentUser
+ ?.delete()
+ ?.addOnSuccessListener { cont.resume(Unit) }
+ ?.addOnFailureListener { cont.resumeWithException(it) }
+ ?: cont.resumeWithException(IllegalStateException("No signed-in user"))
+ }
}
diff --git a/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreCoupleDataSource.kt b/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreCoupleDataSource.kt
index 80a81f85..62903f7c 100644
--- a/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreCoupleDataSource.kt
+++ b/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreCoupleDataSource.kt
@@ -87,6 +87,31 @@ class FirestoreCoupleDataSource @Inject constructor() {
}
}
+ suspend fun leaveCouple(userId: String) {
+ val userSnap = suspendCancellableCoroutine { cont ->
+ db.collection("users").document(userId).get()
+ .addOnSuccessListener { cont.resume(it) }
+ .addOnFailureListener { cont.resumeWithException(it) }
+ }
+ val coupleId = userSnap.getString("coupleId") ?: return
+ val coupleSnap = suspendCancellableCoroutine { cont ->
+ coupleRef(coupleId).get()
+ .addOnSuccessListener { cont.resume(it) }
+ .addOnFailureListener { cont.resumeWithException(it) }
+ }
+ @Suppress("UNCHECKED_CAST")
+ val allUserIds = (coupleSnap.get("userIds") as? List) ?: listOf(userId)
+ suspendCancellableCoroutine { cont ->
+ val batch = db.batch()
+ allUserIds.forEach { uid ->
+ batch.update(db.collection("users").document(uid), "coupleId", null)
+ }
+ batch.commit()
+ .addOnSuccessListener { cont.resume(Unit) }
+ .addOnFailureListener { cont.resumeWithException(it) }
+ }
+ }
+
@Suppress("UNCHECKED_CAST")
private fun DocumentSnapshot.toCouple() = Couple(
id = id,
diff --git a/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreUserDataSource.kt b/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreUserDataSource.kt
index b27473e0..158cef3b 100644
--- a/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreUserDataSource.kt
+++ b/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreUserDataSource.kt
@@ -72,4 +72,28 @@ class FirestoreUserDataSource @Inject constructor() {
}
.addOnFailureListener { cont.resume(false) }
}
+
+ suspend fun storeFcmToken(uid: String, token: String): Unit =
+ suspendCancellableCoroutine { cont ->
+ userRef(uid).set(
+ mapOf("fcmToken" to token, "lastActiveAt" to System.currentTimeMillis()),
+ SetOptions.merge()
+ )
+ .addOnSuccessListener { cont.resume(Unit) }
+ .addOnFailureListener { cont.resumeWithException(it) }
+ }
+
+ suspend fun clearCoupleId(uid: String): Unit =
+ suspendCancellableCoroutine { cont ->
+ userRef(uid).update(mapOf("coupleId" to null))
+ .addOnSuccessListener { cont.resume(Unit) }
+ .addOnFailureListener { cont.resumeWithException(it) }
+ }
+
+ suspend fun deleteUserData(uid: String): Unit =
+ suspendCancellableCoroutine { cont ->
+ userRef(uid).delete()
+ .addOnSuccessListener { cont.resume(Unit) }
+ .addOnFailureListener { cont.resumeWithException(it) }
+ }
}
diff --git a/app/src/main/java/com/couplesconnect/app/data/repository/CoupleRepositoryImpl.kt b/app/src/main/java/com/couplesconnect/app/data/repository/CoupleRepositoryImpl.kt
index 72ca72a9..5e63d073 100644
--- a/app/src/main/java/com/couplesconnect/app/data/repository/CoupleRepositoryImpl.kt
+++ b/app/src/main/java/com/couplesconnect/app/data/repository/CoupleRepositoryImpl.kt
@@ -25,4 +25,8 @@ class CoupleRepositoryImpl @Inject constructor(
override suspend fun updateStreak(coupleId: String): Result = runCatching {
coupleDataSource.updateStreak(coupleId)
}
+
+ override suspend fun leaveCouple(userId: String): Result = runCatching {
+ coupleDataSource.leaveCouple(userId)
+ }
}
diff --git a/app/src/main/java/com/couplesconnect/app/data/repository/FirebaseAuthRepositoryImpl.kt b/app/src/main/java/com/couplesconnect/app/data/repository/FirebaseAuthRepositoryImpl.kt
index 7bba5e8e..8b0e4b07 100644
--- a/app/src/main/java/com/couplesconnect/app/data/repository/FirebaseAuthRepositoryImpl.kt
+++ b/app/src/main/java/com/couplesconnect/app/data/repository/FirebaseAuthRepositoryImpl.kt
@@ -30,4 +30,7 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
runCatching { dataSource.sendPasswordResetEmail(email) }
override suspend fun signOut() = dataSource.signOut()
+
+ override suspend fun deleteAccount(): Result =
+ runCatching { dataSource.deleteAccount() }
}
diff --git a/app/src/main/java/com/couplesconnect/app/data/repository/UserRepositoryImpl.kt b/app/src/main/java/com/couplesconnect/app/data/repository/UserRepositoryImpl.kt
index 6e8c9dea..49ff1cb3 100644
--- a/app/src/main/java/com/couplesconnect/app/data/repository/UserRepositoryImpl.kt
+++ b/app/src/main/java/com/couplesconnect/app/data/repository/UserRepositoryImpl.kt
@@ -19,4 +19,11 @@ class UserRepositoryImpl @Inject constructor(
dataSource.updateDisplayName(uid, displayName)
override suspend fun hasProfile(uid: String): Boolean = dataSource.hasProfile(uid)
+
+ override suspend fun storeFcmToken(uid: String, token: String) =
+ dataSource.storeFcmToken(uid, token)
+
+ override suspend fun clearCoupleId(uid: String) = dataSource.clearCoupleId(uid)
+
+ override suspend fun deleteUserData(uid: String) = dataSource.deleteUserData(uid)
}
diff --git a/app/src/main/java/com/couplesconnect/app/domain/repository/AuthRepository.kt b/app/src/main/java/com/couplesconnect/app/domain/repository/AuthRepository.kt
index 9a3cd5a2..280cb3ca 100644
--- a/app/src/main/java/com/couplesconnect/app/domain/repository/AuthRepository.kt
+++ b/app/src/main/java/com/couplesconnect/app/domain/repository/AuthRepository.kt
@@ -13,4 +13,5 @@ interface AuthRepository {
suspend fun signUpWithEmail(email: String, password: String): Result
suspend fun sendPasswordResetEmail(email: String): Result
suspend fun signOut()
+ suspend fun deleteAccount(): Result
}
diff --git a/app/src/main/java/com/couplesconnect/app/domain/repository/CoupleRepository.kt b/app/src/main/java/com/couplesconnect/app/domain/repository/CoupleRepository.kt
index ce5a90cb..5b55ac2e 100644
--- a/app/src/main/java/com/couplesconnect/app/domain/repository/CoupleRepository.kt
+++ b/app/src/main/java/com/couplesconnect/app/domain/repository/CoupleRepository.kt
@@ -6,4 +6,5 @@ interface CoupleRepository {
suspend fun getCoupleForUser(userId: String): Couple?
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result
suspend fun updateStreak(coupleId: String): Result
+ suspend fun leaveCouple(userId: String): Result
}
diff --git a/app/src/main/java/com/couplesconnect/app/domain/repository/UserRepository.kt b/app/src/main/java/com/couplesconnect/app/domain/repository/UserRepository.kt
index ed33d5c2..6b0a9379 100644
--- a/app/src/main/java/com/couplesconnect/app/domain/repository/UserRepository.kt
+++ b/app/src/main/java/com/couplesconnect/app/domain/repository/UserRepository.kt
@@ -7,4 +7,7 @@ interface UserRepository {
suspend fun createUser(user: User)
suspend fun updateDisplayName(uid: String, displayName: String)
suspend fun hasProfile(uid: String): Boolean
+ suspend fun storeFcmToken(uid: String, token: String)
+ suspend fun clearCoupleId(uid: String)
+ suspend fun deleteUserData(uid: String)
}
diff --git a/app/src/main/java/com/couplesconnect/app/ui/settings/DeleteAccountScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/settings/DeleteAccountScreen.kt
new file mode 100644
index 00000000..2340edfe
--- /dev/null
+++ b/app/src/main/java/com/couplesconnect/app/ui/settings/DeleteAccountScreen.kt
@@ -0,0 +1,192 @@
+package com.couplesconnect.app.ui.settings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.couplesconnect.app.core.navigation.AppRoute
+import com.couplesconnect.app.domain.repository.AuthRepository
+import com.couplesconnect.app.domain.repository.UserRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+
+data class DeleteAccountUiState(
+ val showConfirm: Boolean = false,
+ val isDeleting: Boolean = false,
+ val error: String? = null,
+ val navigateTo: String? = null
+)
+
+@HiltViewModel
+class DeleteAccountViewModel @Inject constructor(
+ private val authRepository: AuthRepository,
+ private val userRepository: UserRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(DeleteAccountUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun requestDelete() = _uiState.update { it.copy(showConfirm = true) }
+ fun dismissDelete() = _uiState.update { it.copy(showConfirm = false) }
+
+ fun confirmDelete() {
+ val uid = authRepository.currentUserId
+ viewModelScope.launch {
+ _uiState.update { it.copy(showConfirm = false, isDeleting = true, error = null) }
+ if (uid != null) {
+ runCatching { userRepository.deleteUserData(uid) }
+ }
+ authRepository.deleteAccount()
+ .onSuccess {
+ _uiState.update { it.copy(isDeleting = false, navigateTo = AppRoute.ONBOARDING) }
+ }
+ .onFailure { e ->
+ _uiState.update { it.copy(isDeleting = false, error = e.message ?: "Could not delete account.") }
+ }
+ }
+ }
+
+ fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DeleteAccountScreen(
+ onNavigate: (String) -> Unit = {},
+ viewModel: DeleteAccountViewModel = hiltViewModel()
+) {
+ val state by viewModel.uiState.collectAsState()
+
+ LaunchedEffect(state.navigateTo) {
+ state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
+ }
+
+ if (state.showConfirm) {
+ AlertDialog(
+ onDismissRequest = viewModel::dismissDelete,
+ title = { Text("Delete your account?") },
+ text = {
+ Text("This permanently removes your profile and sign-in. Your partner will be unpaired. This cannot be undone.")
+ },
+ confirmButton = {
+ Button(
+ onClick = viewModel::confirmDelete,
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
+ ) {
+ Text("Delete", color = Color.White)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = viewModel::dismissDelete) { Text("Cancel") }
+ }
+ )
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Delete account") },
+ navigationIcon = {
+ IconButton(onClick = { onNavigate("back") }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding()
+ .navigationBarsPadding()
+ .verticalScroll(rememberScrollState())
+ .padding(padding)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "Deleting your account is permanent and cannot be reversed. Your profile, sign-in, and pairing will be removed immediately.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "Your partner will be unpaired and can start fresh.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ state.error?.let { err ->
+ Text(
+ text = err,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ Button(
+ onClick = viewModel::requestDelete,
+ enabled = !state.isDeleting,
+ modifier = Modifier.fillMaxWidth().height(52.dp),
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
+ ) {
+ if (state.isDeleting) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
+ Text("Deleting…", color = Color.White)
+ }
+ } else {
+ Text("Delete my account", color = Color.White, fontWeight = FontWeight.SemiBold)
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun DeleteAccountScreenPreview() {
+ DeleteAccountScreen()
+}
diff --git a/app/src/main/java/com/couplesconnect/app/ui/settings/NotificationSettingsScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/settings/NotificationSettingsScreen.kt
index 08930da3..3d879865 100644
--- a/app/src/main/java/com/couplesconnect/app/ui/settings/NotificationSettingsScreen.kt
+++ b/app/src/main/java/com/couplesconnect/app/ui/settings/NotificationSettingsScreen.kt
@@ -1,32 +1,204 @@
package com.couplesconnect.app.ui.settings
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Divider
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
-import com.couplesconnect.app.core.navigation.AppRoute
-import com.couplesconnect.app.ui.components.PlaceholderAction
-import com.couplesconnect.app.ui.components.PlaceholderScreen
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+data class NotificationSettingsUiState(
+ val dailyReminderEnabled: Boolean = true,
+ val partnerAnsweredEnabled: Boolean = true,
+ val streakReminderEnabled: Boolean = false,
+ val quietHoursEnabled: Boolean = false
+)
+
+@HiltViewModel
+class NotificationSettingsViewModel @Inject constructor() : ViewModel() {
+
+ private val _uiState = MutableStateFlow(NotificationSettingsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun toggleDailyReminder(on: Boolean) = _uiState.update { it.copy(dailyReminderEnabled = on) }
+ fun togglePartnerAnswered(on: Boolean) = _uiState.update { it.copy(partnerAnsweredEnabled = on) }
+ fun toggleStreakReminder(on: Boolean) = _uiState.update { it.copy(streakReminderEnabled = on) }
+ fun toggleQuietHours(on: Boolean) = _uiState.update { it.copy(quietHoursEnabled = on) }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotificationSettingsScreen(
- onNavigate: (String) -> Unit = {}
+ onNavigate: (String) -> Unit = {},
+ viewModel: NotificationSettingsViewModel = hiltViewModel()
) {
- PlaceholderScreen(
- title = "Gentle reminders",
- section = "Settings",
- description = "A notification control surface for ritual timing, quiet hours, and partner-aware nudges.",
- route = AppRoute.NOTIFICATIONS,
- onNavigate = onNavigate,
- accent = Color(0xFFF2A65A),
- primaryAction = PlaceholderAction("Privacy", AppRoute.PRIVACY),
- secondaryAction = PlaceholderAction("Settings", AppRoute.SETTINGS),
- chips = listOf("Quiet hours", "Ritual timing", "Opt-in"),
- details = listOf(
- "Reminder settings can be added without push integration yet",
- "Quiet hours can protect sensitive moments",
- "Privacy settings stay adjacent"
+ val state by viewModel.uiState.collectAsState()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Notifications") },
+ navigationIcon = {
+ IconButton(onClick = { onNavigate("back") }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding()
+ .navigationBarsPadding()
+ .verticalScroll(rememberScrollState())
+ .padding(padding)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "Reminders",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
+ )
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
+ ) {
+ Column {
+ NotifToggleRow(
+ label = "Daily question",
+ description = "Remind me to answer today's question",
+ checked = state.dailyReminderEnabled,
+ onCheckedChange = viewModel::toggleDailyReminder
+ )
+ Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
+ NotifToggleRow(
+ label = "Partner answered",
+ description = "Notify me when my partner answers",
+ checked = state.partnerAnsweredEnabled,
+ onCheckedChange = viewModel::togglePartnerAnswered
+ )
+ Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
+ NotifToggleRow(
+ label = "Streak reminder",
+ description = "Nudge me before the streak resets",
+ checked = state.streakReminderEnabled,
+ onCheckedChange = viewModel::toggleStreakReminder
+ )
+ }
+ }
+
+ Spacer(Modifier.height(4.dp))
+
+ Text(
+ text = "Quiet hours",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
+ )
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
+ ) {
+ Column {
+ NotifToggleRow(
+ label = "Enable quiet hours",
+ description = "Suppress notifications 10 PM – 8 AM",
+ checked = state.quietHoursEnabled,
+ onCheckedChange = viewModel::toggleQuietHours
+ )
+ }
+ }
+
+ Spacer(Modifier.height(8.dp))
+
+ Text(
+ text = "Push notification preferences are saved locally. Full scheduling requires the next app update.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 4.dp)
+ )
+ }
+ }
+}
+
+@Composable
+private fun NotifToggleRow(
+ label: String,
+ description: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ colors = SwitchDefaults.colors(checkedThumbColor = Color(0xFFE07A5F), checkedTrackColor = Color(0xFFE07A5F).copy(alpha = 0.4f))
)
- )
+ }
}
@Preview
diff --git a/app/src/main/java/com/couplesconnect/app/ui/settings/RelationshipSettingsScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/settings/RelationshipSettingsScreen.kt
new file mode 100644
index 00000000..338040ae
--- /dev/null
+++ b/app/src/main/java/com/couplesconnect/app/ui/settings/RelationshipSettingsScreen.kt
@@ -0,0 +1,184 @@
+package com.couplesconnect.app.ui.settings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.couplesconnect.app.core.navigation.AppRoute
+import com.couplesconnect.app.domain.repository.AuthRepository
+import com.couplesconnect.app.domain.repository.CoupleRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+
+data class RelationshipSettingsUiState(
+ val showLeaveConfirm: Boolean = false,
+ val isLeaving: Boolean = false,
+ val error: String? = null,
+ val navigateTo: String? = null
+)
+
+@HiltViewModel
+class RelationshipSettingsViewModel @Inject constructor(
+ private val authRepository: AuthRepository,
+ private val coupleRepository: CoupleRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(RelationshipSettingsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun requestLeave() = _uiState.update { it.copy(showLeaveConfirm = true) }
+ fun dismissLeave() = _uiState.update { it.copy(showLeaveConfirm = false) }
+
+ fun confirmLeave() {
+ val uid = authRepository.currentUserId ?: return
+ viewModelScope.launch {
+ _uiState.update { it.copy(showLeaveConfirm = false, isLeaving = true, error = null) }
+ coupleRepository.leaveCouple(uid)
+ .onSuccess {
+ _uiState.update { it.copy(isLeaving = false, navigateTo = AppRoute.CREATE_INVITE) }
+ }
+ .onFailure { e ->
+ _uiState.update { it.copy(isLeaving = false, error = e.message ?: "Could not leave couple.") }
+ }
+ }
+ }
+
+ fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RelationshipSettingsScreen(
+ onNavigate: (String) -> Unit = {},
+ viewModel: RelationshipSettingsViewModel = hiltViewModel()
+) {
+ val state by viewModel.uiState.collectAsState()
+
+ LaunchedEffect(state.navigateTo) {
+ state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
+ }
+
+ if (state.showLeaveConfirm) {
+ AlertDialog(
+ onDismissRequest = viewModel::dismissLeave,
+ title = { Text("Leave this couple?") },
+ text = {
+ Text("Both you and your partner will be unpaired. Your conversation history stays in your account. This cannot be undone.")
+ },
+ confirmButton = {
+ Button(
+ onClick = viewModel::confirmLeave,
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
+ ) {
+ Text("Leave", color = Color.White)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = viewModel::dismissLeave) { Text("Cancel") }
+ }
+ )
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Relationship") },
+ navigationIcon = {
+ IconButton(onClick = { onNavigate("back") }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding()
+ .navigationBarsPadding()
+ .verticalScroll(rememberScrollState())
+ .padding(padding)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "Your relationship data stays private. Leaving unlinks you and your partner — it does not delete your account or answers.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ state.error?.let { err ->
+ Text(
+ text = err,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ Button(
+ onClick = viewModel::requestLeave,
+ enabled = !state.isLeaving,
+ modifier = Modifier.fillMaxWidth().height(52.dp),
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
+ ) {
+ if (state.isLeaving) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
+ Text("Leaving…", color = Color.White)
+ }
+ } else {
+ Text("Leave this couple", color = Color.White, fontWeight = FontWeight.SemiBold)
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun RelationshipSettingsScreenPreview() {
+ RelationshipSettingsScreen()
+}
diff --git a/app/src/main/java/com/couplesconnect/app/ui/settings/SettingsScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/settings/SettingsScreen.kt
index 8a8b46a5..5d13996c 100644
--- a/app/src/main/java/com/couplesconnect/app/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/couplesconnect/app/ui/settings/SettingsScreen.kt
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Star
+import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -205,6 +206,31 @@ fun SettingsScreen(
Spacer(Modifier.height(8.dp))
+ // Danger zone
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
+ ) {
+ Column {
+ SettingsRow(
+ icon = Icons.Filled.Favorite,
+ label = "Leave couple",
+ onClick = { onNavigate(AppRoute.RELATIONSHIP_SETTINGS) },
+ tint = MaterialTheme.colorScheme.error
+ )
+ Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
+ SettingsRow(
+ icon = Icons.Filled.Warning,
+ label = "Delete account",
+ onClick = { onNavigate(AppRoute.DELETE_ACCOUNT) },
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+
+ Spacer(Modifier.height(8.dp))
+
OutlinedButton(
onClick = viewModel::signOut,
enabled = !state.isSigningOut,
@@ -232,7 +258,8 @@ fun SettingsScreen(
private fun SettingsRow(
icon: ImageVector,
label: String,
- onClick: () -> Unit
+ onClick: () -> Unit,
+ tint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurfaceVariant
) {
Row(
modifier = Modifier
@@ -242,11 +269,11 @@ private fun SettingsRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
- Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Icon(icon, contentDescription = null, tint = tint)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurface,
+ color = if (tint == MaterialTheme.colorScheme.onSurfaceVariant) MaterialTheme.colorScheme.onSurface else tint,
modifier = Modifier.weight(1f)
)
Icon(