feat(notifications): FCM messaging service, notification helper, FCM+delete account+relationship screens
This commit is contained in:
parent
011745e7d4
commit
88004cf219
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()}"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue