Home and Library screen update (#101)
* Added support for filtering file types in synced folders * Added support for custom file names and enhanced file information dialog * added feedback option in the homescreen drawer * Implemented a limit for number of recent files displayed on the home screen
This commit is contained in:
parent
9842aea9b1
commit
ddcd253c7b
13 changed files with 637 additions and 734 deletions
|
|
@ -19,20 +19,8 @@
|
|||
*/
|
||||
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 android.content.Intent
|
||||
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
|
||||
|
|
@ -41,72 +29,34 @@ 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.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.outlined.Email
|
||||
import androidx.compose.material.icons.outlined.Feedback
|
||||
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.OutlinedCard
|
||||
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.platform.LocalUriHandler
|
||||
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.core.net.toUri
|
||||
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
|
||||
|
||||
import timber.log.Timber
|
||||
|
||||
private fun launchEmailFeedback(context: android.content.Context) {
|
||||
val intent = Intent(Intent.ACTION_SENDTO).apply {
|
||||
|
|
@ -126,583 +76,135 @@ 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"
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(if (uiState.selectedThreadId == null) "Help & Feedback" else "Support Chat")
|
||||
},
|
||||
title = { Text("Help & Feedback") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
if (uiState.selectedThreadId != null) {
|
||||
viewModel.onBackToThreadList()
|
||||
} else {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}) {
|
||||
IconButton(onClick = { 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") }
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Feedback,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(72.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
val displayedThreads = if (selectedTabIndex == 0) {
|
||||
uiState.threads.filter { it.status == "open" }
|
||||
} else {
|
||||
uiState.threads.filter { it.status == "closed" }
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
ThreadList(
|
||||
threads = displayedThreads,
|
||||
onThreadClick = { viewModel.onThreadSelected(it.id) },
|
||||
emptyMessage = if (selectedTabIndex == 0) "No active tickets" else "No closed tickets",
|
||||
onEmailClick = { launchEmailFeedback(context) }
|
||||
Text(
|
||||
text = "Get in Touch",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Found a bug, have a feature request, or just want to say hi? Let us know on GitHub or send us an email.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
FeedbackOptionCard(
|
||||
title = "GitHub Issues",
|
||||
description = "Report bugs, request features, and track development progress.",
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.github),
|
||||
contentDescription = "GitHub",
|
||||
modifier = Modifier.size(28.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme/issues")
|
||||
}
|
||||
} 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
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
|
||||
FeedbackOptionCard(
|
||||
title = "Email Support",
|
||||
description = "Contact us directly via email for any other inquiries.",
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Email,
|
||||
contentDescription = "Email",
|
||||
modifier = Modifier.size(28.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
TextButton(onClick = onEmailClick) {
|
||||
Icon(
|
||||
Icons.Default.Email,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Contact via Email")
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
launchEmailFeedback(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(threads, key = { it.id }) { thread ->
|
||||
ThreadItem(thread = thread, onClick = { onThreadClick(thread) })
|
||||
HorizontalDivider()
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThreadItem(
|
||||
thread: FeedbackThread,
|
||||
private fun FeedbackOptionCard(
|
||||
title: String,
|
||||
description: String,
|
||||
icon: @Composable () -> Unit,
|
||||
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
|
||||
OutlinedCard(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
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)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
icon()
|
||||
Spacer(modifier = Modifier.width(20.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = "Open",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue