diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt index 1c9fb54..7caf244 100644 --- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt +++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt @@ -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() + val folders = mutableListOf>>() 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() + 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, 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 } } diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index e83f60c..44e2a02 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -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(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 = { diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index b5f8bda..9317bb5 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -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) -> Unit, onDisconnectSyncFolderClick: () -> Unit, downloadingBookIds: Set, 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, + allRecentFiles: List, onAddFolderClick: (android.net.Uri) -> Unit, onRemoveFolderClick: (SyncedFolder) -> Unit, + onEditFolderFiltersClick: (SyncedFolder, Set) -> Unit, onScanNowClick: () -> Unit, onSyncMetadataClick: () -> Unit, isLoading: Boolean ) { + var editingFolder by remember { mutableStateOf(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, + 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) -> 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( diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 324df2d..77a36f8 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -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.entries.toSet() ) data class Shelf(val name: String, val books: List) { @@ -227,6 +227,7 @@ data class ReaderScreenState( val pinnedHomeBookIds: Set = emptySet(), val pinnedLibraryBookIds: Set = 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() + 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) { + 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" } } diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/aryan/reader/SharedComposables.kt index 19034d6..a13c953 100644 --- a/app/src/main/java/com/aryan/reader/SharedComposables.kt +++ b/app/src/main/java/com/aryan/reader/SharedComposables.kt @@ -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) + ) + } + } } } diff --git a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt b/app/src/main/java/com/aryan/reader/data/AppDatabase.kt index 4818ebd..146a9b2 100644 --- a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt +++ b/app/src/main/java/com/aryan/reader/data/AppDatabase.kt @@ -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() diff --git a/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt b/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt index bf8e034..4358f19 100644 --- a/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt +++ b/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt @@ -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 ) } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt index b434389..0ca11a3 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt @@ -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? ) \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt index 759260f..65afffc 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt @@ -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 ) } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt index b7a24cc..a8e04d7 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -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( diff --git a/app/src/main/java/com/aryan/reader/feedback/FeedbackScreen.kt b/app/src/main/java/com/aryan/reader/feedback/FeedbackScreen.kt index 1340ca0..bb8a848 100644 --- a/app/src/main/java/com/aryan/reader/feedback/FeedbackScreen.kt +++ b/app/src/main/java/com/aryan/reader/feedback/FeedbackScreen.kt @@ -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, - 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, - pendingMessages: List, - inputMessage: String, - inputAttachments: List, - onInputChange: (String) -> Unit, - onAttachmentsSelected: (List) -> 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, - onMessageChange: (String) -> Unit, - onCategoryChange: (String) -> Unit, - onAttachmentsSelected: (List) -> 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") - } - } - ) } \ No newline at end of file diff --git a/app/src/main/res/drawable/github.xml b/app/src/main/res/drawable/github.xml new file mode 100644 index 0000000..1305199 --- /dev/null +++ b/app/src/main/res/drawable/github.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt b/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt index acbfae7..437bbb6 100644 --- a/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt +++ b/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt @@ -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(