Home and Library screen update (#101)
* Added support for filtering file types in synced folders * Added support for custom file names and enhanced file information dialog * added feedback option in the homescreen drawer * Implemented a limit for number of recent files displayed on the home screen
This commit is contained in:
parent
9842aea9b1
commit
ddcd253c7b
13 changed files with 637 additions and 734 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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?
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
9
app/src/main/res/drawable/github.xml
Normal file
9
app/src/main/res/drawable/github.xml
Normal 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>
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue