feat(notifications): FCM messaging service, notification helper, FCM+delete account+relationship screens

This commit is contained in:
null 2026-06-16 01:03:07 -05:00
parent 011745e7d4
commit 88004cf219
18 changed files with 814 additions and 23 deletions

View File

@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name=".CouplesConnectApp" android:name=".CouplesConnectApp"
@ -25,6 +26,14 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".core.notifications.AppMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>

View File

@ -41,8 +41,10 @@ import com.couplesconnect.app.ui.questions.QuestionComposerScreen
import com.couplesconnect.app.ui.questions.QuestionPackLibraryScreen import com.couplesconnect.app.ui.questions.QuestionPackLibraryScreen
import com.couplesconnect.app.ui.questions.QuestionThreadScreen import com.couplesconnect.app.ui.questions.QuestionThreadScreen
import com.couplesconnect.app.ui.settings.AccountScreen 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.NotificationSettingsScreen
import com.couplesconnect.app.ui.settings.PrivacyScreen 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.SettingsScreen
import com.couplesconnect.app.ui.settings.SubscriptionScreen import com.couplesconnect.app.ui.settings.SubscriptionScreen
import com.couplesconnect.app.ui.wheel.CategoryPickerScreen import com.couplesconnect.app.ui.wheel.CategoryPickerScreen
@ -242,6 +244,12 @@ fun AppNavigation(
composable(route = AppRoute.SUBSCRIPTION) { composable(route = AppRoute.SUBSCRIPTION) {
SubscriptionScreen(onNavigate = navController::navigate) SubscriptionScreen(onNavigate = navController::navigate)
} }
composable(route = AppRoute.RELATIONSHIP_SETTINGS) {
RelationshipSettingsScreen(onNavigate = navController::navigate)
}
composable(route = AppRoute.DELETE_ACCOUNT) {
DeleteAccountScreen(onNavigate = navController::navigate)
}
} }
} }
} }

View File

@ -30,6 +30,8 @@ object AppRoute {
const val NOTIFICATIONS = "notifications" const val NOTIFICATIONS = "notifications"
const val PRIVACY = "privacy" const val PRIVACY = "privacy"
const val SUBSCRIPTION = "subscription" 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. // Question thread: coupleId and questionId are required; prevId and nextId are optional.
const val QUESTION_THREAD = const val QUESTION_THREAD =
@ -69,7 +71,9 @@ object AppRoute {
Definition(ACCOUNT, "Account", "settings"), Definition(ACCOUNT, "Account", "settings"),
Definition(NOTIFICATIONS, "Notifications", "settings"), Definition(NOTIFICATIONS, "Notifications", "settings"),
Definition(PRIVACY, "Privacy", "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()}" fun answerReveal(questionId: String): String = "answer_reveal/${questionId.asRouteArg()}"

View File

@ -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
)
}
}

View File

@ -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)
}
}
}

View File

@ -65,4 +65,13 @@ class FirebaseAuthDataSource @Inject constructor() {
} }
fun signOut() = auth.signOut() 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"))
}
} }

View File

@ -87,6 +87,31 @@ class FirestoreCoupleDataSource @Inject constructor() {
} }
} }
suspend fun leaveCouple(userId: String) {
val userSnap = suspendCancellableCoroutine<DocumentSnapshot> { cont ->
db.collection("users").document(userId).get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
val coupleId = userSnap.getString("coupleId") ?: return
val coupleSnap = suspendCancellableCoroutine<DocumentSnapshot> { cont ->
coupleRef(coupleId).get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
@Suppress("UNCHECKED_CAST")
val allUserIds = (coupleSnap.get("userIds") as? List<String>) ?: listOf(userId)
suspendCancellableCoroutine<Unit> { 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") @Suppress("UNCHECKED_CAST")
private fun DocumentSnapshot.toCouple() = Couple( private fun DocumentSnapshot.toCouple() = Couple(
id = id, id = id,

View File

@ -72,4 +72,28 @@ class FirestoreUserDataSource @Inject constructor() {
} }
.addOnFailureListener { cont.resume(false) } .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) }
}
} }

View File

@ -25,4 +25,8 @@ class CoupleRepositoryImpl @Inject constructor(
override suspend fun updateStreak(coupleId: String): Result<Unit> = runCatching { override suspend fun updateStreak(coupleId: String): Result<Unit> = runCatching {
coupleDataSource.updateStreak(coupleId) coupleDataSource.updateStreak(coupleId)
} }
override suspend fun leaveCouple(userId: String): Result<Unit> = runCatching {
coupleDataSource.leaveCouple(userId)
}
} }

View File

@ -30,4 +30,7 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
runCatching { dataSource.sendPasswordResetEmail(email) } runCatching { dataSource.sendPasswordResetEmail(email) }
override suspend fun signOut() = dataSource.signOut() override suspend fun signOut() = dataSource.signOut()
override suspend fun deleteAccount(): Result<Unit> =
runCatching { dataSource.deleteAccount() }
} }

View File

@ -19,4 +19,11 @@ class UserRepositoryImpl @Inject constructor(
dataSource.updateDisplayName(uid, displayName) dataSource.updateDisplayName(uid, displayName)
override suspend fun hasProfile(uid: String): Boolean = dataSource.hasProfile(uid) 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)
} }

View File

@ -13,4 +13,5 @@ interface AuthRepository {
suspend fun signUpWithEmail(email: String, password: String): Result<String> suspend fun signUpWithEmail(email: String, password: String): Result<String>
suspend fun sendPasswordResetEmail(email: String): Result<Unit> suspend fun sendPasswordResetEmail(email: String): Result<Unit>
suspend fun signOut() suspend fun signOut()
suspend fun deleteAccount(): Result<Unit>
} }

View File

@ -6,4 +6,5 @@ interface CoupleRepository {
suspend fun getCoupleForUser(userId: String): Couple? suspend fun getCoupleForUser(userId: String): Couple?
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result<String> suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result<String>
suspend fun updateStreak(coupleId: String): Result<Unit> suspend fun updateStreak(coupleId: String): Result<Unit>
suspend fun leaveCouple(userId: String): Result<Unit>
} }

View File

@ -7,4 +7,7 @@ interface UserRepository {
suspend fun createUser(user: User) suspend fun createUser(user: User)
suspend fun updateDisplayName(uid: String, displayName: String) suspend fun updateDisplayName(uid: String, displayName: String)
suspend fun hasProfile(uid: String): Boolean suspend fun hasProfile(uid: String): Boolean
suspend fun storeFcmToken(uid: String, token: String)
suspend fun clearCoupleId(uid: String)
suspend fun deleteUserData(uid: String)
} }

View File

@ -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<DeleteAccountUiState> = _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()
}

View File

@ -1,32 +1,204 @@
package com.couplesconnect.app.ui.settings 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.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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.couplesconnect.app.core.navigation.AppRoute import androidx.compose.ui.unit.dp
import com.couplesconnect.app.ui.components.PlaceholderAction import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.ui.components.PlaceholderScreen
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<NotificationSettingsUiState> = _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 @Composable
fun NotificationSettingsScreen( fun NotificationSettingsScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {},
viewModel: NotificationSettingsViewModel = hiltViewModel()
) { ) {
PlaceholderScreen( val state by viewModel.uiState.collectAsState()
title = "Gentle reminders",
section = "Settings", Scaffold(
description = "A notification control surface for ritual timing, quiet hours, and partner-aware nudges.", topBar = {
route = AppRoute.NOTIFICATIONS, TopAppBar(
onNavigate = onNavigate, title = { Text("Notifications") },
accent = Color(0xFFF2A65A), navigationIcon = {
primaryAction = PlaceholderAction("Privacy", AppRoute.PRIVACY), IconButton(onClick = { onNavigate("back") }) {
secondaryAction = PlaceholderAction("Settings", AppRoute.SETTINGS), Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
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" ) { 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 @Preview

View File

@ -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<RelationshipSettingsUiState> = _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()
}

View File

@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
@ -205,6 +206,31 @@ fun SettingsScreen(
Spacer(Modifier.height(8.dp)) 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( OutlinedButton(
onClick = viewModel::signOut, onClick = viewModel::signOut,
enabled = !state.isSigningOut, enabled = !state.isSigningOut,
@ -232,7 +258,8 @@ fun SettingsScreen(
private fun SettingsRow( private fun SettingsRow(
icon: ImageVector, icon: ImageVector,
label: String, label: String,
onClick: () -> Unit onClick: () -> Unit,
tint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurfaceVariant
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -242,11 +269,11 @@ private fun SettingsRow(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) Icon(icon, contentDescription = null, tint = tint)
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface, color = if (tint == MaterialTheme.colorScheme.onSurfaceVariant) MaterialTheme.colorScheme.onSurface else tint,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
Icon( Icon(