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 jsonString = prefs.getString("synced_folders_list_json", null)
val folders = mutableListOf<String>()
val folders = mutableListOf<Pair<String, Set<FileType>>>()
if (jsonString != null) {
try {
val array = org.json.JSONArray(jsonString)
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) }
} else {
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()) {
@ -83,8 +94,8 @@ class FolderSyncWorker(
syncMutex.withLock {
var allSuccess = true
for (uriString in folders) {
val success = performSyncForFolder(uriString, isMetadataOnly)
for ((uriString, allowedTypes) in folders) {
val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
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
val folderUri = folderUriString.toUri()
@ -195,7 +206,8 @@ class FolderSyncWorker(
file.listFiles().let { fileQueue.addAll(it) }
} else if (file.isFile) {
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)
}
}
@ -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? {
return when {
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(".md", true) -> FileType.MD
name.endsWith(".txt", true) -> FileType.TXT
name.endsWith(".html", true) || name.endsWith(".xhtml", true) || name.endsWith(".htm", true) -> FileType.HTML
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.shape.CircleShape
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.FolderSpecial
import androidx.compose.material.icons.filled.FormatListNumbered
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
@ -157,6 +159,8 @@ fun HomeScreen(
var showUpgradeDialog by remember { mutableStateOf(false) }
var showSignOutConfirmDialog 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 showClearReflowCacheDialog by remember { mutableStateOf(false) }
@ -304,12 +308,19 @@ fun HomeScreen(
},
onShowDeviceManagement = viewModel::showDeviceManagementForDebug,
onFolderSyncToggle = viewModel::setFolderSyncEnabled,
onClearReflowCache = { showClearReflowCacheDialog = true }
onClearReflowCache = { showClearReflowCacheDialog = true },
onRecentFilesLimitChange = viewModel::setRecentFilesLimit
)
} else {
ContextualTopAppBar(
selectedItemCount = selectedContextItems.size,
onNavIconClick = { viewModel.clearContextualAction() },
onInfoClick = {
if (selectedContextItems.size == 1) {
itemForInfoDialog = selectedContextItems.first()
showInfoDialog = true
}
},
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
onDeleteClick = { showDeleteConfirmDialog = true },
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) {
DangerousFolderActionDialog(
title = "Clear Reflow Cache",
@ -680,7 +706,7 @@ fun RecentFileCard(
verticalArrangement = Arrangement.Center
) {
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
} else {
item.displayName
@ -719,9 +745,11 @@ fun DefaultTopAppBar(
onDrawerClick: () -> Unit,
onAboutClick: () -> Unit,
onShowDeviceManagement: () -> Unit,
onFolderSyncToggle: (Boolean) -> Unit
onFolderSyncToggle: (Boolean) -> Unit,
onRecentFilesLimitChange: (Int) -> Unit
) {
var showOptionsMenu by remember { mutableStateOf(false) }
var showLimitMenu by remember { mutableStateOf(false) }
CustomTopAppBar(title = { }, navigationIcon = {
IconButton(onClick = onDrawerClick) {
@ -735,6 +763,30 @@ fun DefaultTopAppBar(
}
}
}, 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)
Box {
IconButton(onClick = { showOptionsMenu = true }) {
@ -964,25 +1016,20 @@ private fun AppDrawerContent(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
if (!isOss) {
NavigationDrawerItem(
icon = {
Icon(
painter = painterResource(id = R.drawable.feedback),
contentDescription = "Feedback"
)
},
label = { Text("Help & Feedback") },
badge = {
if (uiState.hasUnreadFeedback) {
Badge()
}
},
selected = false,
onClick = { navController.navigate("feedback_screen_route") },
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
NavigationDrawerItem(
icon = {
Icon(
painter = painterResource(id = R.drawable.feedback),
contentDescription = "Feedback"
)
},
label = { Text("Help & Feedback") },
selected = false,
onClick = { navController.navigate("feedback_screen_route") },
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
if (!isOss) {
if (uiState.currentUser != null) {
NavigationDrawerItem(
icon = {

View file

@ -26,6 +26,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.FilterList
@ -270,6 +271,7 @@ fun LibraryScreen(
onScanNowClick = viewModel::scanSyncedFolder,
onSyncMetadataClick = viewModel::syncFolderMetadata,
onSelectSyncFolderClick = onSelectSyncFolderClick,
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
syncedFolders = uiState.syncedFolders,
onAddFolderClick = { uri -> viewModel.addSyncedFolder(uri) },
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
@ -328,6 +330,9 @@ fun LibraryScreen(
onDismiss = {
showInfoDialog = false
itemForInfoDialog = null
},
onUpdateName = { newName ->
viewModel.updateCustomName(item.bookId, newName)
}
)
}
@ -439,6 +444,9 @@ fun ShelfScreen(
onDismiss = {
showInfoDialog = false
itemForInfoDialog = null
},
onUpdateName = { newName ->
viewModel.updateCustomName(item.bookId, newName)
}
)
}
@ -484,6 +492,7 @@ fun LibraryScreenContent(
onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit,
onSelectSyncFolderClick: () -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
onDisconnectSyncFolderClick: () -> Unit,
downloadingBookIds: Set<String>,
lastFolderScanTime: Long?,
@ -750,8 +759,10 @@ fun LibraryScreenContent(
2 -> {
FolderSyncScreen(
syncedFolders = syncedFolders,
allRecentFiles = recentFiles,
onAddFolderClick = onAddFolderClick,
onRemoveFolderClick = onRemoveFolderClick,
onEditFolderFiltersClick = onEditFolderFiltersClick,
onScanNowClick = onScanNowClick,
onSyncMetadataClick = onSyncMetadataClick,
isLoading = isLoading || isRefreshing
@ -1302,7 +1313,7 @@ private fun LibraryListItem(
}
Text(
text = item.title ?: item.displayName,
text = item.customName ?: item.title ?: item.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 2,
@ -1458,12 +1469,16 @@ private fun DeleteShelvesConfirmationDialog(
@Composable
private fun FolderSyncScreen(
syncedFolders: List<SyncedFolder>,
allRecentFiles: List<RecentFileItem>,
onAddFolderClick: (android.net.Uri) -> Unit,
onRemoveFolderClick: (SyncedFolder) -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit,
isLoading: Boolean
) {
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
val pickFolderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree()
) { uri ->
@ -1472,8 +1487,6 @@ private fun FolderSyncScreen(
}
}
LocalContext.current
Scaffold(
floatingActionButton = {
if (syncedFolders.size < 3) {
@ -1492,7 +1505,6 @@ private fun FolderSyncScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Global Actions Header
if (syncedFolders.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
@ -1534,28 +1546,54 @@ private fun FolderSyncScreen(
)
}
// List of Folders
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 80.dp) // Space for FAB
contentPadding = PaddingValues(bottom = 80.dp)
) {
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
private fun FolderCard(
folder: SyncedFolder,
onRemoveClick: (SyncedFolder) -> Unit
allRecentFiles: List<RecentFileItem>,
onRemoveClick: (SyncedFolder) -> Unit,
onEditFiltersClick: (SyncedFolder) -> Unit
) {
var showMenu by remember { mutableStateOf(false) }
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 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(
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
@ -1589,6 +1627,13 @@ private fun FolderCard(
Icon(Icons.Default.MoreVert, "Options")
}
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
DropdownMenuItem(
text = { Text("Edit Filters") },
onClick = {
showMenu = false
onEditFiltersClick(folder)
}
)
DropdownMenuItem(
text = { Text("Remove Folder") },
onClick = {
@ -1605,7 +1650,6 @@ private fun FolderCard(
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
// Details
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text(
@ -1617,23 +1661,29 @@ private fun FolderCard(
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) {
Text(
text = "STATUS",
text = "BOOKS",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(6.dp)
.background(Color(0xFF4CAF50), androidx.compose.foundation.shape.CircleShape)
Text(text = totalBooks.toString(), style = MaterialTheme.typography.bodyMedium)
}
}
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)
@Composable
fun LibraryFilterSheet(

View file

@ -148,7 +148,7 @@ data class DeviceLimitReachedState(
)
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>) {
@ -227,6 +227,7 @@ data class ReaderScreenState(
val pinnedHomeBookIds: Set<String> = emptySet(),
val pinnedLibraryBookIds: Set<String> = emptySet(),
val libraryFilters: LibraryFilters = LibraryFilters(),
val recentFilesLimit: Int = 0,
)
open class MainViewModel(application: Application) : AndroidViewModel(application) {
@ -324,7 +325,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
else null,
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 pinned = 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 ->
@ -783,7 +786,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun deleteBookPermanently(bookId: String, onDeleted: () -> Unit = {}) {
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")
@ -1384,7 +1387,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val oldTime = prefs.getLong(KEY_LAST_FOLDER_SCAN_TIME, 0L)
if (oldUri != null) {
val name = getDisplayPathFromUri(appContext, oldUri)
val migrated = SyncedFolder(oldUri, name, oldTime)
val migrated = SyncedFolder(oldUri, name, oldTime, FileType.entries.toSet())
folders.add(migrated)
saveSyncedFoldersToPrefs(folders)
@ -1398,11 +1401,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
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(
SyncedFolder(
uriString = obj.getString("uri"),
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("name", folder.name)
obj.put("lastScanTime", folder.lastScanTime)
val typesArray = JSONArray()
folder.allowedFileTypes.forEach { typesArray.put(it.name) }
obj.put("allowedFileTypes", typesArray)
jsonArray.put(obj)
}
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 newFolder = SyncedFolder(folderUri.toString(), name, 0L)
val newFolder = SyncedFolder(folderUri.toString(), name, 0L, FileType.entries.toSet())
val newStats = currentFolders + newFolder
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() {
viewModelScope.launch {
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) {
_internalState.update { it.copy(sortOrder = sortOrder) }
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 {
private const val KEY_SORT_ORDER = "sort_order"
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
internal const val KEY_PINNED_HOME = "pinned_home_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
import android.content.Context
import android.content.Intent
import android.net.Uri
import timber.log.Timber
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.browser.customtabs.CustomTabsIntent
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.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.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.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.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.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
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 timber.log.Timber
import java.text.SimpleDateFormat
import java.util.Date
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 TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
@ -314,60 +321,196 @@ fun DeleteConfirmationDialog(
}
@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) {
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()) {
InfoRow("Title:", epubTitle, maxLines = 3)
if (displayName != epubTitle) {
InfoRow("File Name:", displayName, maxLines = 2)
}
val pathText = remember(item.sourceFolderUri, item.displayName) {
if (item.sourceFolderUri != null) {
try {
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 {
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)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("OK") }
} else {
"In-App Storage"
}
)
}
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
private fun InfoRow(label: String, value: String?, maxLines: Int = 1) {
if (value.isNullOrBlank()) return
Row {
private fun InfoRowDetailed(
label: String,
value: String,
maxLines: Int = 1,
onCopy: (() -> Unit)? = null
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top
) {
Text(
text = label,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.width(90.dp)
.padding(end = 8.dp)
.width(85.dp)
.padding(top = 2.dp)
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
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.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)
abstract class AppDatabase : RoomDatabase() {
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 {
return INSTANCE ?: synchronized(this) {
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_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
MIGRATION_12_13
MIGRATION_12_13, MIGRATION_13_14
)
.fallbackToDestructiveMigration(false)
.build()

View file

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

View file

@ -48,5 +48,6 @@ data class RecentFileEntity(
val locatorCharOffset: Int?,
val bookmarks: 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 bookmarksJson: String? = null,
val sourceFolderUri: String? = null,
val isReflowPreferred: Boolean = false
val isReflowPreferred: Boolean = false,
val customName: String? = null
) {
fun getUri(): Uri? = uriString?.toUri()
}
@ -71,7 +72,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
isDeleted = this.isDeleted,
bookmarksJson = this.bookmarks,
sourceFolderUri = this.sourceFolderUri,
isReflowPreferred = this.isReflowPreferred
isReflowPreferred = this.isReflowPreferred,
customName = this.customName
)
}
@ -97,7 +99,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
isDeleted = this.isDeleted,
bookmarks = this.bookmarksJson,
sourceFolderUri = this.sourceFolderUri,
isReflowPreferred = this.isReflowPreferred
isReflowPreferred = this.isReflowPreferred,
customName = this.customName
)
}
@ -118,7 +121,8 @@ fun RecentFileItem.toBookMetadata(): BookMetadata {
isDeleted = this.isDeleted,
lastModifiedTimestamp = this.lastModifiedTimestamp,
bookmarksJson = this.bookmarksJson,
hasAnnotations = false
hasAnnotations = false,
customName = this.customName
)
}
@ -142,6 +146,7 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem {
isAvailable = false,
lastModifiedTimestamp = this.lastModifiedTimestamp,
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,
bookmarksJson = entity.bookmarks,
locatorBlockIndex = entity.locatorBlockIndex,
locatorCharOffset = entity.locatorCharOffset
locatorCharOffset = entity.locatorCharOffset,
customName = entity.customName
)
LocalSyncUtils.saveMetadataToFolder(

View file

@ -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")
}
}
)
}

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,
val lastModifiedTimestamp: Long = 0L,
val bookmarksJson: String? = null,
val hasAnnotations: Boolean = false
val hasAnnotations: Boolean = false,
val customName: String? = null
)
data class DeviceItem(