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">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".CouplesConnectApp"
|
||||
|
|
@ -25,6 +26,14 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".core.notifications.AppMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
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")
|
||||
private fun DocumentSnapshot.toCouple() = Couple(
|
||||
id = id,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,4 +25,8 @@ class CoupleRepositoryImpl @Inject constructor(
|
|||
override suspend fun updateStreak(coupleId: String): Result<Unit> = runCatching {
|
||||
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) }
|
||||
|
||||
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)
|
||||
|
||||
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 sendPasswordResetEmail(email: String): Result<Unit>
|
||||
suspend fun signOut()
|
||||
suspend fun deleteAccount(): Result<Unit>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,5 @@ interface CoupleRepository {
|
|||
suspend fun getCoupleForUser(userId: String): Couple?
|
||||
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result<String>
|
||||
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 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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<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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue