Initial commit
This commit is contained in:
commit
6072b2ba29
844 changed files with 220532 additions and 0 deletions
689
app/src/main/java/com/aryan/reader/feedback/FeedbackScreen.kt
Normal file
689
app/src/main/java/com/aryan/reader/feedback/FeedbackScreen.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
274
app/src/main/java/com/aryan/reader/feedback/FeedbackViewModel.kt
Normal file
274
app/src/main/java/com/aryan/reader/feedback/FeedbackViewModel.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue