From 88004cf2199a1389d9c8cb97c9799217ae69427b Mon Sep 17 00:00:00 2001 From: null Date: Tue, 16 Jun 2026 01:03:07 -0500 Subject: [PATCH] feat(notifications): FCM messaging service, notification helper, FCM+delete account+relationship screens --- app/src/main/AndroidManifest.xml | 9 + .../app/core/navigation/AppNavigation.kt | 8 + .../app/core/navigation/AppRoute.kt | 6 +- .../core/notifications/AppMessagingService.kt | 58 +++++ .../core/notifications/NotificationHelper.kt | 60 +++++ .../app/data/remote/FirebaseAuthDataSource.kt | 9 + .../data/remote/FirestoreCoupleDataSource.kt | 25 +++ .../data/remote/FirestoreUserDataSource.kt | 24 ++ .../data/repository/CoupleRepositoryImpl.kt | 4 + .../repository/FirebaseAuthRepositoryImpl.kt | 3 + .../app/data/repository/UserRepositoryImpl.kt | 7 + .../app/domain/repository/AuthRepository.kt | 1 + .../app/domain/repository/CoupleRepository.kt | 1 + .../app/domain/repository/UserRepository.kt | 3 + .../app/ui/settings/DeleteAccountScreen.kt | 192 ++++++++++++++++ .../ui/settings/NotificationSettingsScreen.kt | 210 ++++++++++++++++-- .../ui/settings/RelationshipSettingsScreen.kt | 184 +++++++++++++++ .../app/ui/settings/SettingsScreen.kt | 33 ++- 18 files changed, 814 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/couplesconnect/app/core/notifications/AppMessagingService.kt create mode 100644 app/src/main/java/com/couplesconnect/app/core/notifications/NotificationHelper.kt create mode 100644 app/src/main/java/com/couplesconnect/app/ui/settings/DeleteAccountScreen.kt create mode 100644 app/src/main/java/com/couplesconnect/app/ui/settings/RelationshipSettingsScreen.kt 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(