Initial commit

This commit is contained in:
Aryan 2026-02-24 17:37:40 +05:30
commit 6072b2ba29
844 changed files with 220532 additions and 0 deletions

View file

@ -0,0 +1,689 @@
package com.aryan.reader.feedback
import android.net.Uri
import timber.log.Timber
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
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.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.aryan.reader.R
import com.aryan.reader.data.FeedbackMessage
import com.aryan.reader.data.FeedbackThread
import java.text.SimpleDateFormat
import java.util.Locale
import android.content.Intent
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.outlined.Email
import androidx.core.net.toUri
private fun launchEmailFeedback(context: android.content.Context) {
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:epistemereader@gmail.com".toUri()
putExtra(Intent.EXTRA_SUBJECT, "Feedback: Episteme Reader")
}
try {
context.startActivity(intent)
} catch (e: Exception) {
Timber.e(e, "Could not launch email intent")
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FeedbackScreen(
navController: NavHostController,
viewModel: FeedbackViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val activeThreadsCount = remember(uiState.threads) { uiState.threads.count { it.status == "open" } }
val isLimitReached = activeThreadsCount >= 3
// State for Tabs
var selectedTabIndex by remember { mutableIntStateOf(0) }
LaunchedEffect(uiState.errorMessage) {
uiState.errorMessage?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearError()
}
}
BackHandler(enabled = uiState.selectedThreadId != null) {
viewModel.onBackToThreadList()
}
// Helper to determine if current chat is closed
val currentThread = remember(uiState.selectedThreadId, uiState.threads) {
uiState.threads.find { it.id == uiState.selectedThreadId }
}
val isCurrentChatClosed = currentThread?.status == "closed"
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(if (uiState.selectedThreadId == null) "Help & Feedback" else "Support Chat")
},
navigationIcon = {
IconButton(onClick = {
if (uiState.selectedThreadId != null) {
viewModel.onBackToThreadList()
} else {
navController.popBackStack()
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
if (uiState.selectedThreadId == null) {
IconButton(onClick = { launchEmailFeedback(context) }) {
Icon(
imageVector = Icons.Outlined.Email,
contentDescription = "Send Email Feedback"
)
}
}
}
)
},
floatingActionButton = {
if (uiState.selectedThreadId == null && selectedTabIndex == 0) {
Column(horizontalAlignment = Alignment.End) {
ExtendedFloatingActionButton(
onClick = { if (!isLimitReached) viewModel.onStartCreateTicket() },
icon = { Icon(Icons.Default.Add, "New Ticket") },
text = { Text("New Ticket") },
containerColor = if (isLimitReached) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.primaryContainer,
contentColor = if (isLimitReached) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
if (uiState.selectedThreadId == null) {
// TABS & LIST
Column(modifier = Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = selectedTabIndex) {
Tab(
selected = selectedTabIndex == 0,
onClick = { selectedTabIndex = 0 },
text = { Text("Active") }
)
Tab(
selected = selectedTabIndex == 1,
onClick = { selectedTabIndex = 1 },
text = { Text("Closed") }
)
}
// Limit Hint Text
if (selectedTabIndex == 0 && isLimitReached) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Limit of 3 active tickets reached.",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.padding(vertical = 8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
val displayedThreads = if (selectedTabIndex == 0) {
uiState.threads.filter { it.status == "open" }
} else {
uiState.threads.filter { it.status == "closed" }
}
ThreadList(
threads = displayedThreads,
onThreadClick = { viewModel.onThreadSelected(it.id) },
emptyMessage = if (selectedTabIndex == 0) "No active tickets" else "No closed tickets",
onEmailClick = { launchEmailFeedback(context) }
)
}
} else {
ChatView(
messages = uiState.currentMessages,
pendingMessages = uiState.pendingMessages,
inputMessage = uiState.chatInputMessage,
inputAttachments = uiState.chatInputAttachments,
onInputChange = { viewModel.onChatInputChange(it) },
onAttachmentsSelected = { viewModel.onChatImagesSelected(it) },
onRemoveAttachment = { viewModel.onRemoveChatImage(it) },
onSend = { viewModel.onSendMessage() },
isClosed = isCurrentChatClosed
)
}
if (uiState.isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter))
}
}
}
if (uiState.isCreatingTicket) {
CreateTicketDialog(
message = uiState.newTicketMessage,
category = uiState.newTicketCategory,
attachments = uiState.newTicketAttachments,
onMessageChange = viewModel::onNewTicketMessageChange,
onCategoryChange = viewModel::onNewTicketCategoryChange,
onAttachmentsSelected = viewModel::onNewTicketImagesSelected,
onRemoveAttachment = viewModel::onRemoveNewTicketImage,
onSubmit = viewModel::onSubmitTicket,
onDismiss = viewModel::onCancelCreateTicket
)
}
}
@Composable
fun ThreadList(
threads: List<FeedbackThread>,
onThreadClick: (FeedbackThread) -> Unit,
emptyMessage: String = "No conversations yet",
onEmailClick: () -> Unit
) {
if (threads.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = emptyMessage,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Prefer email or can't sign in?",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
TextButton(onClick = onEmailClick) {
Icon(
Icons.Default.Email,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Contact via Email")
}
}
}
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(threads, key = { it.id }) { thread ->
ThreadItem(thread = thread, onClick = { onThreadClick(thread) })
HorizontalDivider()
}
}
}
}
@Composable
fun ThreadItem(
thread: FeedbackThread,
onClick: () -> Unit
) {
val dateStr = remember(thread.lastUpdated) {
thread.lastUpdated?.let {
SimpleDateFormat("MMM d", Locale.getDefault()).format(it)
} ?: "Just now"
}
val itemAlpha = if (thread.status == "closed") 0.6f else 1f
ListItem(
modifier = Modifier
.clickable(onClick = onClick)
.graphicsLayer { alpha = itemAlpha },
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(thread.category, fontWeight = FontWeight.Bold)
if (thread.hasUnreadAdminReply) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.size(8.dp)
.background(MaterialTheme.colorScheme.error, CircleShape)
)
}
}
},
supportingContent = {
val isPreviewEmpty = thread.preview.isBlank()
val previewText = if (isPreviewEmpty) "Attached Image" else thread.preview
Text(
text = previewText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontStyle = if (isPreviewEmpty) FontStyle.Italic else FontStyle.Normal
)
},
trailingContent = {
Text(dateStr, style = MaterialTheme.typography.labelSmall)
}
)
}
@Composable
fun ChatView(
messages: List<FeedbackMessage>,
pendingMessages: List<FeedbackMessage>,
inputMessage: String,
inputAttachments: List<Uri>,
onInputChange: (String) -> Unit,
onAttachmentsSelected: (List<Uri>) -> Unit,
onRemoveAttachment: (Uri) -> Unit,
onSend: () -> Unit,
isClosed: Boolean = false
) {
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(5),
onResult = { uris -> if (uris.isNotEmpty()) onAttachmentsSelected(uris) }
)
val listState = rememberLazyListState()
val displayedMessages = remember(messages, pendingMessages) {
val realIds = messages.map { it.id }.toSet()
val uniquePending = pendingMessages.filter { it.id !in realIds }
Timber.d("ChatView Recomposition: ${messages.size} real, ${uniquePending.size} pending unique")
messages + uniquePending
}
Column(modifier = Modifier.fillMaxSize()) {
LaunchedEffect(displayedMessages.size) {
if (displayedMessages.isNotEmpty()) {
listState.animateScrollToItem(displayedMessages.size - 1)
}
}
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentPadding = PaddingValues(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(displayedMessages, key = { it.id }) { msg ->
MessageBubble(
message = msg,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
)
}
}
Surface(
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.fillMaxWidth()) {
if (isClosed) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "This ticket is closed.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium
)
}
} else {
if (inputAttachments.isNotEmpty()) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 8.dp, end = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(inputAttachments) { uri ->
Box(modifier = Modifier.size(60.dp)) {
AsyncImage(
model = uri,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.matchParentSize()
.clip(RoundedCornerShape(8.dp))
)
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier
.size(20.dp)
.align(Alignment.TopEnd)
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), CircleShape)
.padding(2.dp)
.clickable { onRemoveAttachment(uri) },
tint = androidx.compose.ui.graphics.Color.White
)
}
}
}
}
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { imagePickerLauncher.launch(PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly)) }) {
Icon(
painter = painterResource(id = R.drawable.image),
contentDescription = "Add Image",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
OutlinedTextField(
value = inputMessage,
onValueChange = onInputChange,
modifier = Modifier.weight(1f),
placeholder = { Text("Type a message...") },
maxLines = 3,
shape = RoundedCornerShape(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = onSend,
enabled = inputMessage.isNotBlank() || inputAttachments.isNotEmpty()
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = if (inputMessage.isNotBlank() || inputAttachments.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
)
}
}
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MessageBubble(
message: FeedbackMessage,
modifier: Modifier = Modifier
) {
val isMe = message.sender == "user"
val alignment = if (isMe) Alignment.End else Alignment.Start
val color = if (isMe) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer
val textColor = if (isMe) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = alignment
) {
Surface(
shape = RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = if (isMe) 16.dp else 4.dp,
bottomEnd = if (isMe) 4.dp else 16.dp
),
color = color,
modifier = Modifier.widthIn(max = 280.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
if (message.attachments.isNotEmpty()) {
FlowRow(
modifier = Modifier.padding(bottom = if(message.text.isNotEmpty()) 8.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
message.attachments.forEach { url ->
SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(url)
.crossfade(true)
.build(),
contentDescription = "Attachment",
modifier = Modifier
.size(100.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentScale = ContentScale.Crop,
error = {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(id = R.drawable.broken_image),
contentDescription = "Image unavailable",
modifier = Modifier.padding(24.dp).fillMaxSize(),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
}
)
}
}
}
if (message.text.isNotEmpty()) {
Text(
text = message.text,
color = textColor
)
}
}
}
message.timestamp?.let {
Text(
text = SimpleDateFormat("h:mm a", Locale.getDefault()).format(it),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 4.dp),
color = MaterialTheme.colorScheme.outline
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateTicketDialog(
message: String,
category: String,
attachments: List<Uri>,
onMessageChange: (String) -> Unit,
onCategoryChange: (String) -> Unit,
onAttachmentsSelected: (List<Uri>) -> Unit,
onRemoveAttachment: (Uri) -> Unit,
onSubmit: () -> Unit,
onDismiss: () -> Unit
) {
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(3),
onResult = { uris -> if (uris.isNotEmpty()) onAttachmentsSelected(uris) }
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New Ticket") },
text = {
Column {
val categories = listOf("Bug Report", "Feature Request", "Feedback", "Other")
Text("Category", style = MaterialTheme.typography.labelLarge)
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
categories.take(2).forEach { cat ->
FilterChip(
selected = category == cat,
onClick = { onCategoryChange(cat) },
label = { Text(cat) },
modifier = Modifier.padding(end = 8.dp)
)
}
}
OutlinedTextField(
value = message,
onValueChange = onMessageChange,
label = { Text("Describe your issue...") },
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
shape = RoundedCornerShape(8.dp)
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
onClick = { imagePickerLauncher.launch(PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly)) }
) {
Icon(painter = painterResource(id = R.drawable.image), contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text("Add Image")
}
Spacer(modifier = Modifier.weight(1f))
Text(
"${attachments.size}/3",
style = MaterialTheme.typography.labelSmall
)
}
if (attachments.isNotEmpty()) {
LazyRow(
modifier = Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(attachments) { uri ->
Box(modifier = Modifier.size(60.dp)) {
AsyncImage(
model = uri,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.matchParentSize()
.clip(RoundedCornerShape(8.dp))
)
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier
.size(20.dp)
.align(Alignment.TopEnd)
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), CircleShape)
.padding(2.dp)
.clickable { onRemoveAttachment(uri) },
tint = androidx.compose.ui.graphics.Color.White
)
}
}
}
}
}
},
confirmButton = {
TextButton(
onClick = onSubmit,
enabled = message.isNotBlank()
) {
Text("Submit")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View file

@ -0,0 +1,274 @@
package com.aryan.reader.feedback
import android.app.Application
import android.content.Context
import android.net.Uri
import timber.log.Timber
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.aryan.reader.AuthRepository
import com.aryan.reader.data.FeedbackMessage
import com.aryan.reader.data.FeedbackRepository
import com.aryan.reader.data.FeedbackThread
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.Date
data class FeedbackUiState(
val threads: List<FeedbackThread> = emptyList(),
val currentMessages: List<FeedbackMessage> = emptyList(),
val pendingMessages: List<FeedbackMessage> = emptyList(),
val selectedThreadId: String? = null,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isCreatingTicket: Boolean = false,
val newTicketMessage: String = "",
val newTicketCategory: String = "Bug Report",
val newTicketAttachments: List<Uri> = emptyList(),
val chatInputMessage: String = "",
val chatInputAttachments: List<Uri> = emptyList()
)
class FeedbackViewModel(application: Application) : AndroidViewModel(application) {
private val repository = FeedbackRepository(application)
private val authRepository = AuthRepository(application)
private val _uiState = MutableStateFlow(FeedbackUiState())
val uiState = _uiState.asStateFlow()
private var threadsListener: Any? = null
private var messagesListener: Any? = null
private val currentUser = authRepository.getSignedInUser()
init {
if (currentUser != null) {
startListeningToThreads(currentUser.uid)
} else {
_uiState.update { it.copy(errorMessage = "You must be signed in to use feedback.") }
}
}
private fun startListeningToThreads(userId: String) {
_uiState.update { it.copy(isLoading = true) }
threadsListener = repository.listenToFeedbackThreads(userId) { threads ->
_uiState.update { it.copy(threads = threads, isLoading = false) }
}
}
fun onThreadSelected(threadId: String) {
Timber.d("VM: onThreadSelected $threadId")
repository.removeListener(messagesListener)
_uiState.update {
it.copy(
selectedThreadId = threadId,
isLoading = true,
currentMessages = emptyList(),
pendingMessages = emptyList(),
chatInputMessage = "",
chatInputAttachments = emptyList()
)
}
viewModelScope.launch {
repository.markThreadAsRead(threadId)
}
messagesListener = repository.listenToMessages(threadId) { messages ->
Timber.d("VM: Listener update received. ${messages.size} messages.")
viewModelScope.launch {
repository.markThreadAsRead(threadId)
}
_uiState.update { state ->
val realIds = messages.map { it.id }.toSet()
val remainingPending = state.pendingMessages.filter { it.id !in realIds }
if (remainingPending.size != state.pendingMessages.size) {
Timber.d("VM: Reconciliation - Removed ${state.pendingMessages.size - remainingPending.size} pending messages.")
}
state.copy(
currentMessages = messages,
pendingMessages = remainingPending,
isLoading = false
)
}
}
}
fun onBackToThreadList() {
Timber.d("VM: Back to thread list")
repository.removeListener(messagesListener)
messagesListener = null
_uiState.update { it.copy(selectedThreadId = null, currentMessages = emptyList(), pendingMessages = emptyList()) }
}
fun onStartCreateTicket() {
if (currentUser == null) {
_uiState.update { it.copy(errorMessage = "You must be signed in to submit feedback.") }
return
}
_uiState.update { it.copy(isCreatingTicket = true) }
}
fun onCancelCreateTicket() {
_uiState.update { it.copy(isCreatingTicket = false, newTicketMessage = "", newTicketAttachments = emptyList()) }
}
fun onNewTicketMessageChange(text: String) {
_uiState.update { it.copy(newTicketMessage = text) }
}
fun onNewTicketCategoryChange(category: String) {
_uiState.update { it.copy(newTicketCategory = category) }
}
fun onNewTicketImagesSelected(uris: List<Uri>) {
val current = _uiState.value.newTicketAttachments
if (current.size + uris.size > 3) {
_uiState.update { it.copy(errorMessage = "Max 3 images allowed for tickets.") }
return
}
validateAndAddImages(uris) { validUris ->
_uiState.update { it.copy(newTicketAttachments = it.newTicketAttachments + validUris) }
}
}
fun onRemoveNewTicketImage(uri: Uri) {
_uiState.update { it.copy(newTicketAttachments = it.newTicketAttachments - uri) }
}
fun onChatInputChange(text: String) {
_uiState.update { it.copy(chatInputMessage = text) }
}
fun onChatImagesSelected(uris: List<Uri>) {
val current = _uiState.value.chatInputAttachments
if (current.size + uris.size > 5) {
_uiState.update { it.copy(errorMessage = "Max 5 images allowed per message.") }
return
}
validateAndAddImages(uris) { validUris ->
_uiState.update { it.copy(chatInputAttachments = it.chatInputAttachments + validUris) }
}
}
fun onRemoveChatImage(uri: Uri) {
_uiState.update { it.copy(chatInputAttachments = it.chatInputAttachments - uri) }
}
private fun validateAndAddImages(uris: List<Uri>, onValid: (List<Uri>) -> Unit) {
val context = getApplication<Application>()
val maxFileSize = 5 * 1024 * 1024 // 5 MB
val validUris = mutableListOf<Uri>()
for (uri in uris) {
val fileSize = getFileSize(context, uri)
if (fileSize > maxFileSize) {
_uiState.update { it.copy(errorMessage = "One or more images exceed the 5MB limit.") }
return
}
validUris.add(uri)
}
onValid(validUris)
}
private fun getFileSize(context: Context, uri: Uri): Long {
return try {
context.contentResolver.openFileDescriptor(uri, "r")?.use { it.statSize } ?: 0L
} catch (_: Exception) { 0L }
}
fun onSubmitTicket() {
val state = _uiState.value
if (state.newTicketMessage.isBlank()) return
val user = currentUser ?: return
onCancelCreateTicket()
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val threadId = repository.createThread(
userId = user.uid,
category = state.newTicketCategory,
message = state.newTicketMessage,
attachmentUris = state.newTicketAttachments
)
onThreadSelected(threadId)
} catch (e: Exception) {
Timber.e(e, "ViewModel: Error submitting ticket")
_uiState.update { it.copy(errorMessage = "Failed to create ticket: ${e.message}") }
} finally {
_uiState.update { it.copy(isLoading = false) }
}
}
}
fun onSendMessage() {
val state = _uiState.value
val threadId = state.selectedThreadId ?: return
val textToSend = state.chatInputMessage.trim()
val attachmentsToSend = state.chatInputAttachments
if (textToSend.isBlank() && attachmentsToSend.isEmpty()) return
val user = currentUser ?: return
val messageId = repository.generateMessageId()
Timber.d("VM: Sending message. Generated ID: $messageId")
// Updated reference to id
val pendingMessage = FeedbackMessage(
id = messageId,
text = textToSend,
sender = "user",
timestamp = Date(),
attachments = attachmentsToSend.map { it.toString() }
)
_uiState.update {
it.copy(
chatInputMessage = "",
chatInputAttachments = emptyList(),
pendingMessages = it.pendingMessages + pendingMessage
)
}
viewModelScope.launch {
try {
repository.addMessage(
threadId = threadId,
messageId = messageId,
uid = user.uid,
message = textToSend,
sender = "user",
attachmentUris = attachmentsToSend
)
Timber.d("VM: addMessage returned success for $messageId")
} catch (e: Exception) {
Timber.e(e, "ViewModel: Error sending message")
_uiState.update {
it.copy(
errorMessage = "Failed to send: ${e.message}",
// Updated reference to id
pendingMessages = it.pendingMessages.filterNot { msg -> msg.id == messageId },
chatInputMessage = textToSend
)
}
}
}
}
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
override fun onCleared() {
super.onCleared()
repository.removeListener(messagesListener)
repository.removeListener(threadsListener)
}
}

View file

@ -0,0 +1,67 @@
package com.aryan.reader.feedback
import android.content.Context
import android.os.Build
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.aryan.reader.BuildConfig
import com.aryan.reader.data.FeedbackRepository
import com.aryan.reader.data.FeedbackTextPayload
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.core.net.toUri
import com.aryan.reader.AuthRepository
class FeedbackWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
private val feedbackRepository = FeedbackRepository(applicationContext)
private val authRepository = AuthRepository(applicationContext)
override suspend fun doWork(): Result {
val message = inputData.getString(KEY_MESSAGE) ?: return Result.failure()
val category = inputData.getString(KEY_CATEGORY) ?: return Result.failure()
val imageUris = inputData.getStringArray(KEY_IMAGE_URIS)?.map { it.toUri() } ?: emptyList()
val userId = authRepository.getSignedInUser()?.uid ?: "Not signed in"
val contextMap = mapOf(
"appVersion" to BuildConfig.VERSION_NAME,
"deviceModel" to "${Build.MANUFACTURER} ${Build.MODEL}",
"androidVersion" to Build.VERSION.SDK_INT.toString(),
"userId" to userId
)
val payload = FeedbackTextPayload(message, category, contextMap)
return withContext(Dispatchers.IO) {
val textResult = feedbackRepository.sendFeedbackText(payload)
val response = textResult.getOrNull()
if (textResult.isFailure || response?.status != "success" || response.thread_ts == null) {
return@withContext if (runAttemptCount < 3) Result.retry() else Result.failure()
}
val threadTs = response.thread_ts
imageUris.forEach { uri ->
val imageResult = feedbackRepository.sendFeedbackImage(uri, threadTs)
if (imageResult.isFailure) {
return@withContext Result.failure(workDataOf("error" to "Image upload failed"))
}
}
Result.success()
}
}
companion object {
const val KEY_MESSAGE = "KEY_MESSAGE"
const val KEY_CATEGORY = "KEY_CATEGORY"
const val KEY_IMAGE_URIS = "KEY_IMAGE_URIS"
}
}