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:
Aryan 2026-03-20 21:51:03 +05:30 committed by GitHub
parent 9842aea9b1
commit ddcd253c7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 637 additions and 734 deletions

View file

@ -58,18 +58,29 @@ class FolderSyncWorker(
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val jsonString = prefs.getString("synced_folders_list_json", null) val jsonString = prefs.getString("synced_folders_list_json", null)
val folders = mutableListOf<String>() val folders = mutableListOf<Pair<String, Set<FileType>>>()
if (jsonString != null) { if (jsonString != null) {
try { try {
val array = org.json.JSONArray(jsonString) val array = org.json.JSONArray(jsonString)
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
folders.add(array.getJSONObject(i).getString("uri")) val obj = array.getJSONObject(i)
val uri = obj.getString("uri")
val allowedFileTypes = mutableSetOf<FileType>()
if (obj.has("allowedFileTypes")) {
val typesArray = obj.getJSONArray("allowedFileTypes")
for (j in 0 until typesArray.length()) {
try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {}
}
} else {
allowedFileTypes.addAll(FileType.entries)
}
folders.add(Pair(uri, allowedFileTypes))
} }
} catch (e: Exception) { Timber.e(e) } } catch (e: Exception) { Timber.e(e) }
} else { } else {
val single = prefs.getString("synced_folder_uri", null) val single = prefs.getString("synced_folder_uri", null)
if (single != null) folders.add(single) if (single != null) folders.add(Pair(single, FileType.entries.toSet()))
} }
if (folders.isEmpty()) { if (folders.isEmpty()) {
@ -83,8 +94,8 @@ class FolderSyncWorker(
syncMutex.withLock { syncMutex.withLock {
var allSuccess = true var allSuccess = true
for (uriString in folders) { for ((uriString, allowedTypes) in folders) {
val success = performSyncForFolder(uriString, isMetadataOnly) val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
if (!success) allSuccess = false if (!success) allSuccess = false
} }
@ -104,7 +115,7 @@ class FolderSyncWorker(
} }
} }
private suspend fun performSyncForFolder(folderUriString: String, metadataOnly: Boolean): Boolean { private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set<FileType>, metadataOnly: Boolean): Boolean {
if (folderUriString.isBlank()) return true if (folderUriString.isBlank()) return true
val folderUri = folderUriString.toUri() val folderUri = folderUriString.toUri()
@ -195,7 +206,8 @@ class FolderSyncWorker(
file.listFiles().let { fileQueue.addAll(it) } file.listFiles().let { fileQueue.addAll(it) }
} else if (file.isFile) { } else if (file.isFile) {
val name = file.name ?: "" val name = file.name ?: ""
if (isValidExtension(name) && !name.endsWith(".json") && !name.startsWith(".")) { val type = getFileType(name, file.type)
if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) {
currentDiskFiles.add(file) currentDiskFiles.add(file)
} }
} }
@ -298,14 +310,6 @@ class FolderSyncWorker(
} }
} }
private fun isValidExtension(name: String): Boolean {
return name.endsWith(".pdf", true) ||
name.endsWith(".epub", true) ||
name.endsWith(".mobi", true) ||
name.endsWith(".azw3", true) ||
name.endsWith(".md", true)
}
private fun getFileType(name: String, mimeType: String?): FileType? { private fun getFileType(name: String, mimeType: String?): FileType? {
return when { return when {
mimeType == "application/pdf" || name.endsWith(".pdf", true) -> FileType.PDF mimeType == "application/pdf" || name.endsWith(".pdf", true) -> FileType.PDF
@ -313,6 +317,7 @@ class FolderSyncWorker(
name.endsWith(".mobi", true) || name.endsWith(".azw3", true) -> FileType.MOBI name.endsWith(".mobi", true) || name.endsWith(".azw3", true) -> FileType.MOBI
name.endsWith(".md", true) -> FileType.MD name.endsWith(".md", true) -> FileType.MD
name.endsWith(".txt", true) -> FileType.TXT name.endsWith(".txt", true) -> FileType.TXT
name.endsWith(".html", true) || name.endsWith(".xhtml", true) || name.endsWith(".htm", true) -> FileType.HTML
else -> null else -> null
} }
} }

View file

@ -54,8 +54,10 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderSpecial import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.FormatListNumbered
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
@ -157,6 +159,8 @@ fun HomeScreen(
var showUpgradeDialog by remember { mutableStateOf(false) } var showUpgradeDialog by remember { mutableStateOf(false) }
var showSignOutConfirmDialog by remember { mutableStateOf(false) } var showSignOutConfirmDialog by remember { mutableStateOf(false) }
var showAboutDialog by remember { mutableStateOf(false) } var showAboutDialog by remember { mutableStateOf(false) }
var showInfoDialog by remember { mutableStateOf(false) }
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
var showClearBookCacheDialog by remember { mutableStateOf(false) } var showClearBookCacheDialog by remember { mutableStateOf(false) }
var showClearReflowCacheDialog by remember { mutableStateOf(false) } var showClearReflowCacheDialog by remember { mutableStateOf(false) }
@ -304,12 +308,19 @@ fun HomeScreen(
}, },
onShowDeviceManagement = viewModel::showDeviceManagementForDebug, onShowDeviceManagement = viewModel::showDeviceManagementForDebug,
onFolderSyncToggle = viewModel::setFolderSyncEnabled, onFolderSyncToggle = viewModel::setFolderSyncEnabled,
onClearReflowCache = { showClearReflowCacheDialog = true } onClearReflowCache = { showClearReflowCacheDialog = true },
onRecentFilesLimitChange = viewModel::setRecentFilesLimit
) )
} else { } else {
ContextualTopAppBar( ContextualTopAppBar(
selectedItemCount = selectedContextItems.size, selectedItemCount = selectedContextItems.size,
onNavIconClick = { viewModel.clearContextualAction() }, onNavIconClick = { viewModel.clearContextualAction() },
onInfoClick = {
if (selectedContextItems.size == 1) {
itemForInfoDialog = selectedContextItems.first()
showInfoDialog = true
}
},
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) }, onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
onDeleteClick = { showDeleteConfirmDialog = true }, onDeleteClick = { showDeleteConfirmDialog = true },
onSelectAllClick = { viewModel.selectAllRecentFiles() }) onSelectAllClick = { viewModel.selectAllRecentFiles() })
@ -407,6 +418,21 @@ fun HomeScreen(
) )
} }
itemForInfoDialog?.let { item ->
if (showInfoDialog) {
FileInfoDialog(
item = item,
onDismiss = {
showInfoDialog = false
itemForInfoDialog = null
},
onUpdateName = { newName ->
viewModel.updateCustomName(item.bookId, newName)
}
)
}
}
if (showClearReflowCacheDialog) { if (showClearReflowCacheDialog) {
DangerousFolderActionDialog( DangerousFolderActionDialog(
title = "Clear Reflow Cache", title = "Clear Reflow Cache",
@ -680,7 +706,7 @@ fun RecentFileCard(
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text( Text(
text = if ((item.type == FileType.EPUB || item.type == FileType.MOBI) && !item.title.isNullOrBlank()) { text = item.customName ?: if ((item.type == FileType.EPUB || item.type == FileType.MOBI) && !item.title.isNullOrBlank()) {
item.title item.title
} else { } else {
item.displayName item.displayName
@ -719,9 +745,11 @@ fun DefaultTopAppBar(
onDrawerClick: () -> Unit, onDrawerClick: () -> Unit,
onAboutClick: () -> Unit, onAboutClick: () -> Unit,
onShowDeviceManagement: () -> Unit, onShowDeviceManagement: () -> Unit,
onFolderSyncToggle: (Boolean) -> Unit onFolderSyncToggle: (Boolean) -> Unit,
onRecentFilesLimitChange: (Int) -> Unit
) { ) {
var showOptionsMenu by remember { mutableStateOf(false) } var showOptionsMenu by remember { mutableStateOf(false) }
var showLimitMenu by remember { mutableStateOf(false) }
CustomTopAppBar(title = { }, navigationIcon = { CustomTopAppBar(title = { }, navigationIcon = {
IconButton(onClick = onDrawerClick) { IconButton(onClick = onDrawerClick) {
@ -735,6 +763,30 @@ fun DefaultTopAppBar(
} }
} }
}, actions = { }, actions = {
// Recent Files Limit Menu
Box {
IconButton(onClick = { showLimitMenu = true }) {
Icon(Icons.Default.FormatListNumbered, contentDescription = "Recent Files Limit")
}
DropdownMenu(
expanded = showLimitMenu, onDismissRequest = { showLimitMenu = false }
) {
val limitOptions = listOf(0, 10, 20, 50, 100)
limitOptions.forEach { limit ->
DropdownMenuItem(
text = { Text(if (limit == 0) "No limit" else "$limit files") },
onClick = {
onRecentFilesLimitChange(limit)
showLimitMenu = false
},
trailingIcon = if (uiState.recentFilesLimit == limit) {
{ Icon(Icons.Default.Check, contentDescription = "Selected") }
} else null
)
}
}
}
// Options Menu (MoreVert) // Options Menu (MoreVert)
Box { Box {
IconButton(onClick = { showOptionsMenu = true }) { IconButton(onClick = { showOptionsMenu = true }) {
@ -964,25 +1016,20 @@ private fun AppDrawerContent(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
) )
if (!isOss) { NavigationDrawerItem(
NavigationDrawerItem( icon = {
icon = { Icon(
Icon( painter = painterResource(id = R.drawable.feedback),
painter = painterResource(id = R.drawable.feedback), contentDescription = "Feedback"
contentDescription = "Feedback" )
) },
}, label = { Text("Help & Feedback") },
label = { Text("Help & Feedback") }, selected = false,
badge = { onClick = { navController.navigate("feedback_screen_route") },
if (uiState.hasUnreadFeedback) { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
Badge() )
}
},
selected = false,
onClick = { navController.navigate("feedback_screen_route") },
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
if (!isOss) {
if (uiState.currentUser != null) { if (uiState.currentUser != null) {
NavigationDrawerItem( NavigationDrawerItem(
icon = { icon = {

View file

@ -26,6 +26,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.FilterList
@ -270,6 +271,7 @@ fun LibraryScreen(
onScanNowClick = viewModel::scanSyncedFolder, onScanNowClick = viewModel::scanSyncedFolder,
onSyncMetadataClick = viewModel::syncFolderMetadata, onSyncMetadataClick = viewModel::syncFolderMetadata,
onSelectSyncFolderClick = onSelectSyncFolderClick, onSelectSyncFolderClick = onSelectSyncFolderClick,
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
syncedFolders = uiState.syncedFolders, syncedFolders = uiState.syncedFolders,
onAddFolderClick = { uri -> viewModel.addSyncedFolder(uri) }, onAddFolderClick = { uri -> viewModel.addSyncedFolder(uri) },
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) }, onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
@ -328,6 +330,9 @@ fun LibraryScreen(
onDismiss = { onDismiss = {
showInfoDialog = false showInfoDialog = false
itemForInfoDialog = null itemForInfoDialog = null
},
onUpdateName = { newName ->
viewModel.updateCustomName(item.bookId, newName)
} }
) )
} }
@ -439,6 +444,9 @@ fun ShelfScreen(
onDismiss = { onDismiss = {
showInfoDialog = false showInfoDialog = false
itemForInfoDialog = null itemForInfoDialog = null
},
onUpdateName = { newName ->
viewModel.updateCustomName(item.bookId, newName)
} }
) )
} }
@ -484,6 +492,7 @@ fun LibraryScreenContent(
onScanNowClick: () -> Unit, onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit, onSyncMetadataClick: () -> Unit,
onSelectSyncFolderClick: () -> Unit, onSelectSyncFolderClick: () -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
onDisconnectSyncFolderClick: () -> Unit, onDisconnectSyncFolderClick: () -> Unit,
downloadingBookIds: Set<String>, downloadingBookIds: Set<String>,
lastFolderScanTime: Long?, lastFolderScanTime: Long?,
@ -750,8 +759,10 @@ fun LibraryScreenContent(
2 -> { 2 -> {
FolderSyncScreen( FolderSyncScreen(
syncedFolders = syncedFolders, syncedFolders = syncedFolders,
allRecentFiles = recentFiles,
onAddFolderClick = onAddFolderClick, onAddFolderClick = onAddFolderClick,
onRemoveFolderClick = onRemoveFolderClick, onRemoveFolderClick = onRemoveFolderClick,
onEditFolderFiltersClick = onEditFolderFiltersClick,
onScanNowClick = onScanNowClick, onScanNowClick = onScanNowClick,
onSyncMetadataClick = onSyncMetadataClick, onSyncMetadataClick = onSyncMetadataClick,
isLoading = isLoading || isRefreshing isLoading = isLoading || isRefreshing
@ -1302,7 +1313,7 @@ private fun LibraryListItem(
} }
Text( Text(
text = item.title ?: item.displayName, text = item.customName ?: item.title ?: item.displayName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 2, maxLines = 2,
@ -1458,12 +1469,16 @@ private fun DeleteShelvesConfirmationDialog(
@Composable @Composable
private fun FolderSyncScreen( private fun FolderSyncScreen(
syncedFolders: List<SyncedFolder>, syncedFolders: List<SyncedFolder>,
allRecentFiles: List<RecentFileItem>,
onAddFolderClick: (android.net.Uri) -> Unit, onAddFolderClick: (android.net.Uri) -> Unit,
onRemoveFolderClick: (SyncedFolder) -> Unit, onRemoveFolderClick: (SyncedFolder) -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
onScanNowClick: () -> Unit, onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit, onSyncMetadataClick: () -> Unit,
isLoading: Boolean isLoading: Boolean
) { ) {
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
val pickFolderLauncher = rememberLauncherForActivityResult( val pickFolderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree() contract = ActivityResultContracts.OpenDocumentTree()
) { uri -> ) { uri ->
@ -1472,8 +1487,6 @@ private fun FolderSyncScreen(
} }
} }
LocalContext.current
Scaffold( Scaffold(
floatingActionButton = { floatingActionButton = {
if (syncedFolders.size < 3) { if (syncedFolders.size < 3) {
@ -1492,7 +1505,6 @@ private fun FolderSyncScreen(
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Global Actions Header
if (syncedFolders.isNotEmpty()) { if (syncedFolders.isNotEmpty()) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -1534,28 +1546,54 @@ private fun FolderSyncScreen(
) )
} }
// List of Folders
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 80.dp) // Space for FAB contentPadding = PaddingValues(bottom = 80.dp)
) { ) {
items(syncedFolders, key = { it.uriString }) { folder -> items(syncedFolders, key = { it.uriString }) { folder ->
FolderCard(folder, onRemoveFolderClick) FolderCard(
folder = folder,
allRecentFiles = allRecentFiles,
onRemoveClick = onRemoveFolderClick,
onEditFiltersClick = { editingFolder = folder }
)
} }
} }
} }
} }
if (editingFolder != null) {
EditFolderFiltersDialog(
folder = editingFolder!!,
onConfirm = { newFilters ->
onEditFolderFiltersClick(editingFolder!!, newFilters)
editingFolder = null
},
onDismiss = { editingFolder = null }
)
}
} }
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable @Composable
private fun FolderCard( private fun FolderCard(
folder: SyncedFolder, folder: SyncedFolder,
onRemoveClick: (SyncedFolder) -> Unit allRecentFiles: List<RecentFileItem>,
onRemoveClick: (SyncedFolder) -> Unit,
onEditFiltersClick: (SyncedFolder) -> Unit
) { ) {
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) } val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
val lastScanText = if (folder.lastScanTime == 0L) "Never" else dateFormat.format(Date(folder.lastScanTime)) val lastScanText = if (folder.lastScanTime == 0L) "Never" else dateFormat.format(Date(folder.lastScanTime))
val folderFiles = remember(allRecentFiles, folder.uriString) {
allRecentFiles.filter { it.sourceFolderUri == folder.uriString }
}
val totalBooks = folderFiles.size
val countsByType = remember(folderFiles) {
folderFiles.groupBy { it.type }.mapValues { it.value.size }
}
androidx.compose.material3.ElevatedCard( androidx.compose.material3.ElevatedCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.elevatedCardColors( colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
@ -1589,6 +1627,13 @@ private fun FolderCard(
Icon(Icons.Default.MoreVert, "Options") Icon(Icons.Default.MoreVert, "Options")
} }
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
DropdownMenuItem(
text = { Text("Edit Filters") },
onClick = {
showMenu = false
onEditFiltersClick(folder)
}
)
DropdownMenuItem( DropdownMenuItem(
text = { Text("Remove Folder") }, text = { Text("Remove Folder") },
onClick = { onClick = {
@ -1605,7 +1650,6 @@ private fun FolderCard(
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
// Details
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
@ -1617,23 +1661,29 @@ private fun FolderCard(
Text(text = lastScanText, style = MaterialTheme.typography.bodySmall) Text(text = lastScanText, style = MaterialTheme.typography.bodySmall)
} }
// You could add Book Count here if we queried it from DB
// For now, let's just show Status
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) { Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) {
Text( Text(
text = "STATUS", text = "BOOKS",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Row(verticalAlignment = Alignment.CenterVertically) { Text(text = totalBooks.toString(), style = MaterialTheme.typography.bodyMedium)
Box( }
modifier = Modifier }
.size(6.dp)
.background(Color(0xFF4CAF50), androidx.compose.foundation.shape.CircleShape) if (countsByType.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
countsByType.forEach { (type, count) ->
AssistChip(
onClick = { },
label = { Text("${type.name}: $count") }
) )
Spacer(modifier = Modifier.width(4.dp))
Text(text = "Active", style = MaterialTheme.typography.bodySmall)
} }
} }
} }
@ -1641,6 +1691,58 @@ private fun FolderCard(
} }
} }
@Composable
private fun EditFolderFiltersDialog(
folder: SyncedFolder,
onConfirm: (Set<FileType>) -> Unit,
onDismiss: () -> Unit
) {
var selectedTypes by remember { mutableStateOf(folder.allowedFileTypes) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Filter File Types") },
text = {
Column {
Text(
text = "Select the file types you want to sync from this folder:",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(8.dp))
FileType.entries.forEach { type ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
selectedTypes = if (type in selectedTypes) selectedTypes - type else selectedTypes + type
}
.padding(vertical = 4.dp)
) {
androidx.compose.material3.Checkbox(
checked = type in selectedTypes,
onCheckedChange = { checked ->
selectedTypes = if (checked) selectedTypes + type else selectedTypes - type
}
)
Spacer(modifier = Modifier.width(8.dp))
Text(type.name)
}
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(selectedTypes) },
enabled = selectedTypes.isNotEmpty()
) { Text("Save") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LibraryFilterSheet( fun LibraryFilterSheet(

View file

@ -148,7 +148,7 @@ data class DeviceLimitReachedState(
) )
data class SyncedFolder( data class SyncedFolder(
val uriString: String, val name: String, val lastScanTime: Long val uriString: String, val name: String, val lastScanTime: Long, val allowedFileTypes: Set<FileType> = FileType.entries.toSet()
) )
data class Shelf(val name: String, val books: List<RecentFileItem>) { data class Shelf(val name: String, val books: List<RecentFileItem>) {
@ -227,6 +227,7 @@ data class ReaderScreenState(
val pinnedHomeBookIds: Set<String> = emptySet(), val pinnedHomeBookIds: Set<String> = emptySet(),
val pinnedLibraryBookIds: Set<String> = emptySet(), val pinnedLibraryBookIds: Set<String> = emptySet(),
val libraryFilters: LibraryFilters = LibraryFilters(), val libraryFilters: LibraryFilters = LibraryFilters(),
val recentFilesLimit: Int = 0,
) )
open class MainViewModel(application: Application) : AndroidViewModel(application) { open class MainViewModel(application: Application) : AndroidViewModel(application) {
@ -324,7 +325,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
else null, else null,
pinnedHomeBookIds = prefs.getStringSet(KEY_PINNED_HOME, emptySet()) ?: emptySet(), pinnedHomeBookIds = prefs.getStringSet(KEY_PINNED_HOME, emptySet()) ?: emptySet(),
pinnedLibraryBookIds = prefs.getStringSet(KEY_PINNED_LIBRARY, emptySet()) ?: emptySet() pinnedLibraryBookIds = prefs.getStringSet(KEY_PINNED_LIBRARY, emptySet()) ?: emptySet(),
recentFilesLimit = prefs.getInt(KEY_RECENT_FILES_LIMIT, 0)
) )
) )
@ -377,7 +379,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val visibleRecentFiles = sortFiles(baseVisibleFiles.filter { it.isRecent }).let { list -> val visibleRecentFiles = sortFiles(baseVisibleFiles.filter { it.isRecent }).let { list ->
val pinned = list.filter { it.bookId in internalState.pinnedHomeBookIds } val pinned = list.filter { it.bookId in internalState.pinnedHomeBookIds }
val unpinned = list.filter { it.bookId !in internalState.pinnedHomeBookIds } val unpinned = list.filter { it.bookId !in internalState.pinnedHomeBookIds }
pinned + unpinned val combined = pinned + unpinned
if (internalState.recentFilesLimit > 0) combined.take(internalState.recentFilesLimit) else combined
} }
val validContextualItems = internalState.contextualActionItems.filter { contextItem -> val validContextualItems = internalState.contextualActionItems.filter { contextItem ->
@ -783,7 +786,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun deleteBookPermanently(bookId: String, onDeleted: () -> Unit = {}) { fun deleteBookPermanently(bookId: String, onDeleted: () -> Unit = {}) {
viewModelScope.launch { viewModelScope.launch {
val item = recentFilesRepository.getFileByBookId(bookId) ?: return@launch @Suppress("UnusedVariable", "Unused") val item = recentFilesRepository.getFileByBookId(bookId) ?: return@launch
Timber.d("Deleting book permanently from reader: $bookId") Timber.d("Deleting book permanently from reader: $bookId")
@ -1384,7 +1387,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val oldTime = prefs.getLong(KEY_LAST_FOLDER_SCAN_TIME, 0L) val oldTime = prefs.getLong(KEY_LAST_FOLDER_SCAN_TIME, 0L)
if (oldUri != null) { if (oldUri != null) {
val name = getDisplayPathFromUri(appContext, oldUri) val name = getDisplayPathFromUri(appContext, oldUri)
val migrated = SyncedFolder(oldUri, name, oldTime) val migrated = SyncedFolder(oldUri, name, oldTime, FileType.entries.toSet())
folders.add(migrated) folders.add(migrated)
saveSyncedFoldersToPrefs(folders) saveSyncedFoldersToPrefs(folders)
@ -1398,11 +1401,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val jsonArray = JSONArray(jsonString) val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) { for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i) val obj = jsonArray.getJSONObject(i)
val allowedFileTypes = mutableSetOf<FileType>()
if (obj.has("allowedFileTypes")) {
val typesArray = obj.getJSONArray("allowedFileTypes")
for (j in 0 until typesArray.length()) {
try {
allowedFileTypes.add(FileType.valueOf(typesArray.getString(j)))
} catch (_: Exception) {}
}
} else {
allowedFileTypes.addAll(FileType.entries)
}
folders.add( folders.add(
SyncedFolder( SyncedFolder(
uriString = obj.getString("uri"), uriString = obj.getString("uri"),
name = obj.getString("name"), name = obj.getString("name"),
lastScanTime = obj.optLong("lastScanTime", 0L) lastScanTime = obj.optLong("lastScanTime", 0L),
allowedFileTypes = allowedFileTypes
) )
) )
} }
@ -1420,6 +1437,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
obj.put("uri", folder.uriString) obj.put("uri", folder.uriString)
obj.put("name", folder.name) obj.put("name", folder.name)
obj.put("lastScanTime", folder.lastScanTime) obj.put("lastScanTime", folder.lastScanTime)
val typesArray = JSONArray()
folder.allowedFileTypes.forEach { typesArray.put(it.name) }
obj.put("allowedFileTypes", typesArray)
jsonArray.put(obj) jsonArray.put(obj)
} }
prefs.edit { putString(KEY_SYNCED_FOLDERS_JSON, jsonArray.toString()) } prefs.edit { putString(KEY_SYNCED_FOLDERS_JSON, jsonArray.toString()) }
@ -1446,7 +1466,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
val name = getDisplayPathFromUri(appContext, folderUri.toString()) val name = getDisplayPathFromUri(appContext, folderUri.toString())
val newFolder = SyncedFolder(folderUri.toString(), name, 0L) val newFolder = SyncedFolder(folderUri.toString(), name, 0L, FileType.entries.toSet())
val newStats = currentFolders + newFolder val newStats = currentFolders + newFolder
saveSyncedFoldersToPrefs(newStats) saveSyncedFoldersToPrefs(newStats)
@ -1578,6 +1598,37 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
fun updateFolderFilters(folder: SyncedFolder, newFilters: Set<FileType>) {
viewModelScope.launch(Dispatchers.IO) {
val currentFolders = _internalState.value.syncedFolders.toMutableList()
val index = currentFolders.indexOfFirst { it.uriString == folder.uriString }
if (index != -1) {
val updatedFolder = folder.copy(allowedFileTypes = newFilters)
currentFolders[index] = updatedFolder
saveSyncedFoldersToPrefs(currentFolders)
_internalState.update { it.copy(syncedFolders = currentFolders) }
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
.filter { it.type !in newFilters }
if (filesToRemove.isNotEmpty()) {
Timber.d("Removing ${filesToRemove.size} files that no longer match the filter for folder ${folder.name}")
val idsToRemove = filesToRemove.map { it.bookId }
idsToRemove.forEach { bookId ->
pdfTextRepository.clearBookText(bookId)
clearImportedFileCache(bookId)
}
recentFilesRepository.deleteFilePermanently(idsToRemove)
}
withContext(Dispatchers.Main) {
scanSyncedFolder()
}
}
}
}
fun disconnectAllSyncedFolders() { fun disconnectAllSyncedFolders() {
viewModelScope.launch { viewModelScope.launch {
val folders = _internalState.value.syncedFolders val folders = _internalState.value.syncedFolders
@ -2374,6 +2425,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
fun setRecentFilesLimit(limit: Int) {
_internalState.update { it.copy(recentFilesLimit = limit) }
prefs.edit { putInt(KEY_RECENT_FILES_LIMIT, limit) }
}
fun setSortOrder(sortOrder: SortOrder) { fun setSortOrder(sortOrder: SortOrder) {
_internalState.update { it.copy(sortOrder = sortOrder) } _internalState.update { it.copy(sortOrder = sortOrder) }
prefs.edit { putString(KEY_SORT_ORDER, sortOrder.name) } prefs.edit { putString(KEY_SORT_ORDER, sortOrder.name) }
@ -3799,6 +3855,26 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
fun updateCustomName(bookId: String, newName: String?) {
viewModelScope.launch {
val item = recentFilesRepository.getFileByBookId(bookId)
if (item != null) {
val updatedItem = item.copy(customName = newName, lastModifiedTimestamp = System.currentTimeMillis())
recentFilesRepository.addRecentFile(updatedItem)
if (uiState.value.isSyncEnabled) {
uploadSingleBookMetadata(updatedItem)
}
if (updatedItem.sourceFolderUri != null) {
launch(Dispatchers.IO) {
recentFilesRepository.syncLocalMetadataToFolder(bookId)
}
}
}
}
}
companion object { companion object {
private const val KEY_SORT_ORDER = "sort_order" private const val KEY_SORT_ORDER = "sort_order"
internal const val KEY_SHELVES = "shelf_names" internal const val KEY_SHELVES = "shelf_names"
@ -3817,5 +3893,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private const val MAX_FOLDER_LIMIT = 3 private const val MAX_FOLDER_LIMIT = 3
internal const val KEY_PINNED_HOME = "pinned_home_books" internal const val KEY_PINNED_HOME = "pinned_home_books"
internal const val KEY_PINNED_LIBRARY = "pinned_library_books" internal const val KEY_PINNED_LIBRARY = "pinned_library_books"
private const val KEY_RECENT_FILES_LIMIT = "recent_files_limit"
} }
} }

View file

@ -19,80 +19,87 @@
*/ */
package com.aryan.reader package com.aryan.reader
import android.content.Context
import android.content.Intent
import android.net.Uri import android.net.Uri
import timber.log.Timber
import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material3.LocalTextStyle
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.text.TextStyle
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.outlined.FileOpen import androidx.compose.material.icons.outlined.FileOpen
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.core.net.toUri
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import timber.log.Timber
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import androidx.compose.foundation.text.ClickableText
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.sp
import android.content.Context
import android.content.Intent
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.UriHandler
import androidx.core.net.toUri
internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html" internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html" internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
@ -314,60 +321,196 @@ fun DeleteConfirmationDialog(
} }
@Composable @Composable
fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit) { fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (String?) -> Unit) {
LocalContext.current
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
val originalName = item.title ?: item.displayName
var editingName by remember { mutableStateOf(item.customName ?: originalName) }
val hasCustomName = item.customName != null
val formattedDate = remember(item.timestamp) { val formattedDate = remember(item.timestamp) {
SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(item.timestamp)) SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(item.timestamp))
} }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("File Information") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val displayName = item.displayName
val epubTitle = item.title
if (item.type != FileType.PDF && !epubTitle.isNullOrBlank()) { val pathText = remember(item.sourceFolderUri, item.displayName) {
InfoRow("Title:", epubTitle, maxLines = 3) if (item.sourceFolderUri != null) {
if (displayName != epubTitle) { try {
InfoRow("File Name:", displayName, maxLines = 2) val uri = item.sourceFolderUri.toUri()
} val docId = android.provider.DocumentsContract.getTreeDocumentId(uri)
val split = docId.split(":")
val storageName = if (split[0].equals("primary", ignoreCase = true)) "Internal storage" else split[0]
val relativePath = if (split.size > 1) {
Uri.decode(split[1]).removeSuffix("/")
} else ""
val leadingSlash = if (relativePath.isNotEmpty() && !relativePath.startsWith("/")) "/" else ""
"/$storageName$leadingSlash$relativePath/${item.displayName}"
} catch (_: Exception) {
val decoded = Uri.decode(item.sourceFolderUri)
if (decoded.contains("primary:")) {
"/Internal storage/${decoded.substringAfter("primary:").removeSuffix("/")}/${item.displayName}"
} else { } else {
InfoRow("File Name:", displayName, maxLines = 2) item.displayName
} }
item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
InfoRow("Author:", it, maxLines = 2)
}
InfoRow("File Type:", item.type.name)
InfoRow("Date Added:", formattedDate)
} }
}, } else {
confirmButton = { "In-App Storage"
TextButton(onClick = onDismiss) { Text("OK") }
} }
) }
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"File Information",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
androidx.compose.material3.OutlinedTextField(
value = editingName,
onValueChange = { editingName = it },
label = { Text("Book Name") },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp, max = 130.dp),
maxLines = 4,
textStyle = MaterialTheme.typography.bodyLarge,
trailingIcon = {
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(editingName))
}) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy Name", modifier = Modifier.size(20.dp))
}
}
)
if (hasCustomName) {
Text(
text = "Original Name: $originalName",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
TextButton(
onClick = {
editingName = originalName
onUpdateName(null)
},
modifier = Modifier.align(Alignment.End),
contentPadding = PaddingValues(0.dp)
) {
Text("Revert to Original")
}
} else if (originalName != item.displayName) {
Text(
text = "File Name: ${item.displayName}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
InfoRowDetailed("Author", it)
}
InfoRowDetailed("Format", item.type.name)
InfoRowDetailed("Added", formattedDate)
InfoRowDetailed(
label = "Location",
value = pathText,
maxLines = 4,
onCopy = {
clipboardManager.setText(AnnotatedString(pathText))
}
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) { Text("Cancel") }
Spacer(modifier = Modifier.width(8.dp))
androidx.compose.material3.Button(onClick = {
val finalName = editingName.trim()
if (finalName != (item.customName ?: originalName)) {
if (finalName == originalName || finalName.isEmpty()) {
onUpdateName(null)
} else {
onUpdateName(finalName)
}
}
onDismiss()
}) { Text("Save") }
}
}
}
}
} }
@Composable @Composable
private fun InfoRow(label: String, value: String?, maxLines: Int = 1) { private fun InfoRowDetailed(
if (value.isNullOrBlank()) return label: String,
Row { value: String,
maxLines: Int = 1,
onCopy: (() -> Unit)? = null
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top
) {
Text( Text(
text = label, text = label,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier modifier = Modifier
.width(90.dp) .width(85.dp)
.padding(end = 8.dp) .padding(top = 2.dp)
) )
Text( Text(
text = value, text = value,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = maxLines, maxLines = maxLines,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(top = 2.dp)
) )
if (onCopy != null) {
IconButton(
onClick = onCopy,
modifier = Modifier
.size(24.dp)
.padding(start = 4.dp)
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Copy $label",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
)
}
}
} }
} }

View file

@ -27,7 +27,7 @@ import androidx.room.TypeConverters
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
@Database(entities = [RecentFileEntity::class, CustomFontEntity::class], version = 13, exportSchema = false) @Database(entities =[RecentFileEntity::class, CustomFontEntity::class], version = 14, exportSchema = false)
@TypeConverters(FileTypeConverter::class) @TypeConverters(FileTypeConverter::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun recentFileDao(): RecentFileDao abstract fun recentFileDao(): RecentFileDao
@ -173,6 +173,12 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
val MIGRATION_13_14 = object : Migration(13, 14) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN customName TEXT DEFAULT NULL")
}
}
fun getDatabase(context: Context): AppDatabase { fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) { return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder( val instance = Room.databaseBuilder(
@ -184,7 +190,7 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
MIGRATION_12_13 MIGRATION_12_13, MIGRATION_13_14
) )
.fallbackToDestructiveMigration(false) .fallbackToDestructiveMigration(false)
.build() .build()

View file

@ -18,7 +18,8 @@ data class FolderBookMetadata(
val lastModifiedTimestamp: Long, val lastModifiedTimestamp: Long,
val bookmarksJson: String?, val bookmarksJson: String?,
val locatorBlockIndex: Int?, val locatorBlockIndex: Int?,
val locatorCharOffset: Int? val locatorCharOffset: Int?,
val customName: String?
) { ) {
fun toJsonString(): String { fun toJsonString(): String {
val json = JSONObject() val json = JSONObject()
@ -36,6 +37,7 @@ data class FolderBookMetadata(
json.put("bookmarksJson", bookmarksJson) json.put("bookmarksJson", bookmarksJson)
json.put("locatorBlockIndex", locatorBlockIndex ?: -1) json.put("locatorBlockIndex", locatorBlockIndex ?: -1)
json.put("locatorCharOffset", locatorCharOffset ?: -1) json.put("locatorCharOffset", locatorCharOffset ?: -1)
json.put("customName", customName)
return json.toString() return json.toString()
} }
@ -66,7 +68,8 @@ data class FolderBookMetadata(
lastModifiedTimestamp = json.optLong("lastModifiedTimestamp", 0L), lastModifiedTimestamp = json.optLong("lastModifiedTimestamp", 0L),
bookmarksJson = json.optStringNull("bookmarksJson"), bookmarksJson = json.optStringNull("bookmarksJson"),
locatorBlockIndex = json.optIntNull("locatorBlockIndex"), locatorBlockIndex = json.optIntNull("locatorBlockIndex"),
locatorCharOffset = json.optIntNull("locatorCharOffset") locatorCharOffset = json.optIntNull("locatorCharOffset"),
customName = json.optStringNull("customName")
) )
} }
} }
@ -93,6 +96,7 @@ fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?,
lastModifiedTimestamp = this.lastModifiedTimestamp, lastModifiedTimestamp = this.lastModifiedTimestamp,
isDeleted = false, isDeleted = false,
bookmarksJson = this.bookmarksJson, bookmarksJson = this.bookmarksJson,
sourceFolderUri = sourceFolderUri sourceFolderUri = sourceFolderUri,
customName = this.customName
) )
} }

View file

@ -48,5 +48,6 @@ data class RecentFileEntity(
val locatorCharOffset: Int?, val locatorCharOffset: Int?,
val bookmarks: String?, val bookmarks: String?,
@ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?, @ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?,
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean @ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
@ColumnInfo(defaultValue = "NULL") val customName: String?
) )

View file

@ -44,7 +44,8 @@ data class RecentFileItem(
val isDeleted: Boolean = false, val isDeleted: Boolean = false,
val bookmarksJson: String? = null, val bookmarksJson: String? = null,
val sourceFolderUri: String? = null, val sourceFolderUri: String? = null,
val isReflowPreferred: Boolean = false val isReflowPreferred: Boolean = false,
val customName: String? = null
) { ) {
fun getUri(): Uri? = uriString?.toUri() fun getUri(): Uri? = uriString?.toUri()
} }
@ -71,7 +72,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
isDeleted = this.isDeleted, isDeleted = this.isDeleted,
bookmarksJson = this.bookmarks, bookmarksJson = this.bookmarks,
sourceFolderUri = this.sourceFolderUri, sourceFolderUri = this.sourceFolderUri,
isReflowPreferred = this.isReflowPreferred isReflowPreferred = this.isReflowPreferred,
customName = this.customName
) )
} }
@ -97,7 +99,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
isDeleted = this.isDeleted, isDeleted = this.isDeleted,
bookmarks = this.bookmarksJson, bookmarks = this.bookmarksJson,
sourceFolderUri = this.sourceFolderUri, sourceFolderUri = this.sourceFolderUri,
isReflowPreferred = this.isReflowPreferred isReflowPreferred = this.isReflowPreferred,
customName = this.customName
) )
} }
@ -118,7 +121,8 @@ fun RecentFileItem.toBookMetadata(): BookMetadata {
isDeleted = this.isDeleted, isDeleted = this.isDeleted,
lastModifiedTimestamp = this.lastModifiedTimestamp, lastModifiedTimestamp = this.lastModifiedTimestamp,
bookmarksJson = this.bookmarksJson, bookmarksJson = this.bookmarksJson,
hasAnnotations = false hasAnnotations = false,
customName = this.customName
) )
} }
@ -142,6 +146,7 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem {
isAvailable = false, isAvailable = false,
lastModifiedTimestamp = this.lastModifiedTimestamp, lastModifiedTimestamp = this.lastModifiedTimestamp,
isDeleted = this.isDeleted, isDeleted = this.isDeleted,
bookmarksJson = this.bookmarksJson bookmarksJson = this.bookmarksJson,
customName = this.customName
) )
} }

View file

@ -158,7 +158,8 @@ class RecentFilesRepository(private val context: Context) {
lastModifiedTimestamp = entity.lastModifiedTimestamp, lastModifiedTimestamp = entity.lastModifiedTimestamp,
bookmarksJson = entity.bookmarks, bookmarksJson = entity.bookmarks,
locatorBlockIndex = entity.locatorBlockIndex, locatorBlockIndex = entity.locatorBlockIndex,
locatorCharOffset = entity.locatorCharOffset locatorCharOffset = entity.locatorCharOffset,
customName = entity.customName
) )
LocalSyncUtils.saveMetadataToFolder( LocalSyncUtils.saveMetadataToFolder(

View file

@ -19,20 +19,8 @@
*/ */
package com.aryan.reader.feedback package com.aryan.reader.feedback
import android.net.Uri import android.content.Intent
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.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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.outlined.Email
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.outlined.Feedback
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold 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.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier 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.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController 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.R
import com.aryan.reader.data.FeedbackMessage import timber.log.Timber
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) { private fun launchEmailFeedback(context: android.content.Context) {
val intent = Intent(Intent.ACTION_SENDTO).apply { val intent = Intent(Intent.ACTION_SENDTO).apply {
@ -126,583 +76,135 @@ fun FeedbackScreen(
navController: NavHostController, navController: NavHostController,
viewModel: FeedbackViewModel = viewModel() viewModel: FeedbackViewModel = viewModel()
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val uriHandler = LocalUriHandler.current
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( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = { Text("Help & Feedback") },
Text(if (uiState.selectedThreadId == null) "Help & Feedback" else "Support Chat")
},
navigationIcon = { navigationIcon = {
IconButton(onClick = { IconButton(onClick = { navController.popBackStack() }) {
if (uiState.selectedThreadId != null) {
viewModel.onBackToThreadList()
} else {
navController.popBackStack()
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") 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 -> ) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) { Column(
if (uiState.selectedThreadId == null) { modifier = Modifier
// TABS & LIST .fillMaxSize()
Column(modifier = Modifier.fillMaxSize()) { .padding(paddingValues)
TabRow(selectedTabIndex = selectedTabIndex) { .padding(horizontal = 16.dp),
Tab( horizontalAlignment = Alignment.CenterHorizontally
selected = selectedTabIndex == 0, ) {
onClick = { selectedTabIndex = 0 }, Spacer(modifier = Modifier.height(32.dp))
text = { Text("Active") }
)
Tab(
selected = selectedTabIndex == 1,
onClick = { selectedTabIndex = 1 },
text = { Text("Closed") }
)
}
// Limit Hint Text Icon(
if (selectedTabIndex == 0 && isLimitReached) { imageVector = Icons.Outlined.Feedback,
Surface( contentDescription = null,
color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(72.dp),
modifier = Modifier.fillMaxWidth() tint = MaterialTheme.colorScheme.primary
) { )
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) { Spacer(modifier = Modifier.height(16.dp))
uiState.threads.filter { it.status == "open" }
} else {
uiState.threads.filter { it.status == "closed" }
}
ThreadList( Text(
threads = displayedThreads, text = "Get in Touch",
onThreadClick = { viewModel.onThreadSelected(it.id) }, style = MaterialTheme.typography.headlineMedium,
emptyMessage = if (selectedTabIndex == 0) "No active tickets" else "No closed tickets", fontWeight = FontWeight.Bold
onEmailClick = { launchEmailFeedback(context) } )
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) { Spacer(modifier = Modifier.height(16.dp))
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter))
}
}
}
if (uiState.isCreatingTicket) { FeedbackOptionCard(
CreateTicketDialog( title = "Email Support",
message = uiState.newTicketMessage, description = "Contact us directly via email for any other inquiries.",
category = uiState.newTicketCategory, icon = {
attachments = uiState.newTicketAttachments, Icon(
onMessageChange = viewModel::onNewTicketMessageChange, imageVector = Icons.Outlined.Email,
onCategoryChange = viewModel::onNewTicketCategoryChange, contentDescription = "Email",
onAttachmentsSelected = viewModel::onNewTicketImagesSelected, modifier = Modifier.size(28.dp),
onRemoveAttachment = viewModel::onRemoveNewTicketImage, tint = MaterialTheme.colorScheme.primary
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( onClick = {
Icons.Default.Email, launchEmailFeedback(context)
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Contact via Email")
}
} }
} )
}
} else { Spacer(modifier = Modifier.weight(1f))
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(threads, key = { it.id }) { thread ->
ThreadItem(thread = thread, onClick = { onThreadClick(thread) })
HorizontalDivider()
}
} }
} }
} }
@Composable @Composable
fun ThreadItem( private fun FeedbackOptionCard(
thread: FeedbackThread, title: String,
description: String,
icon: @Composable () -> Unit,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val dateStr = remember(thread.lastUpdated) { OutlinedCard(
thread.lastUpdated?.let { onClick = onClick,
SimpleDateFormat("MMM d", Locale.getDefault()).format(it) modifier = Modifier.fillMaxWidth(),
} ?: "Just now" shape = RoundedCornerShape(16.dp)
}
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( Row(
shape = RoundedCornerShape( modifier = Modifier
topStart = 16.dp, .fillMaxWidth()
topEnd = 16.dp, .padding(20.dp),
bottomStart = if (isMe) 16.dp else 4.dp, verticalAlignment = Alignment.CenterVertically
bottomEnd = if (isMe) 4.dp else 16.dp
),
color = color,
modifier = Modifier.widthIn(max = 280.dp)
) { ) {
Column(modifier = Modifier.padding(12.dp)) { icon()
if (message.attachments.isNotEmpty()) { Spacer(modifier = Modifier.width(20.dp))
FlowRow( Column(modifier = Modifier.weight(1f)) {
modifier = Modifier.padding(bottom = if(message.text.isNotEmpty()) 8.dp else 0.dp), Text(
horizontalArrangement = Arrangement.spacedBy(4.dp), text = title,
verticalArrangement = Arrangement.spacedBy(4.dp) style = MaterialTheme.typography.titleMedium,
) { fontWeight = FontWeight.Bold
message.attachments.forEach { url -> )
SubcomposeAsyncImage( Spacer(modifier = Modifier.height(4.dp))
model = ImageRequest.Builder(LocalContext.current) Text(
.data(url) text = description,
.crossfade(true) style = MaterialTheme.typography.bodyMedium,
.build(), color = MaterialTheme.colorScheme.onSurfaceVariant
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
)
}
} }
} Spacer(modifier = Modifier.width(8.dp))
Icon(
message.timestamp?.let { imageVector = Icons.AutoMirrored.Filled.ArrowForward,
Text( contentDescription = "Open",
text = SimpleDateFormat("h:mm a", Locale.getDefault()).format(it), tint = MaterialTheme.colorScheme.onSurfaceVariant
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,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,0.297c-6.63,0 -12,5.373 -12,12 0,5.303 3.438,9.8 8.205,11.385 0.6,0.113 0.82,-0.258 0.82,-0.577 0,-0.285 -0.01,-1.04 -0.015,-2.04 -3.338,0.724 -4.042,-1.61 -4.042,-1.61C4.422,18.07 3.633,17.7 3.633,17.7c-1.087,-0.744 0.084,-0.729 0.084,-0.729 1.205,0.084 1.838,1.236 1.838,1.236 1.07,1.835 2.809,1.305 3.495,0.998 0.108,-0.776 0.417,-1.305 0.76,-1.605 -2.665,-0.3 -5.466,-1.332 -5.466,-5.93 0,-1.31 0.465,-2.38 1.235,-3.22 -0.135,-0.303 -0.54,-1.523 0.105,-3.176 0,0 1.005,-0.322 3.3,1.23 0.96,-0.267 1.98,-0.399 3,-0.405 1.02,0.006 2.04,0.138 3,0.405 2.28,-1.552 3.285,-1.23 3.285,-1.23 0.645,1.653 0.24,2.873 0.12,3.176 0.765,0.84 1.23,1.91 1.23,3.22 0,4.61 -2.805,5.625 -5.475,5.92 0.42,0.36 0.81,1.096 0.81,2.22 0,1.606 -0.015,2.896 -0.015,3.286 0,0.315 0.21,0.69 0.825,0.57C20.565,22.092 24,17.592 24,12.297c0,-6.627 -5.373,-12 -12,-12"/>
</vector>

View file

@ -22,7 +22,8 @@ data class BookMetadata(
var isDeleted: Boolean = false, var isDeleted: Boolean = false,
val lastModifiedTimestamp: Long = 0L, val lastModifiedTimestamp: Long = 0L,
val bookmarksJson: String? = null, val bookmarksJson: String? = null,
val hasAnnotations: Boolean = false val hasAnnotations: Boolean = false,
val customName: String? = null
) )
data class DeviceItem( data class DeviceItem(