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 prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
val jsonString = prefs.getString("synced_folders_list_json", null)
|
val jsonString = prefs.getString("synced_folders_list_json", null)
|
||||||
val folders = mutableListOf<String>()
|
val folders = mutableListOf<Pair<String, Set<FileType>>>()
|
||||||
|
|
||||||
if (jsonString != null) {
|
if (jsonString != null) {
|
||||||
try {
|
try {
|
||||||
val array = org.json.JSONArray(jsonString)
|
val array = org.json.JSONArray(jsonString)
|
||||||
for (i in 0 until array.length()) {
|
for (i in 0 until array.length()) {
|
||||||
folders.add(array.getJSONObject(i).getString("uri"))
|
val obj = array.getJSONObject(i)
|
||||||
|
val uri = obj.getString("uri")
|
||||||
|
val allowedFileTypes = mutableSetOf<FileType>()
|
||||||
|
if (obj.has("allowedFileTypes")) {
|
||||||
|
val typesArray = obj.getJSONArray("allowedFileTypes")
|
||||||
|
for (j in 0 until typesArray.length()) {
|
||||||
|
try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
allowedFileTypes.addAll(FileType.entries)
|
||||||
|
}
|
||||||
|
folders.add(Pair(uri, allowedFileTypes))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) { Timber.e(e) }
|
} catch (e: Exception) { Timber.e(e) }
|
||||||
} else {
|
} else {
|
||||||
val single = prefs.getString("synced_folder_uri", null)
|
val single = prefs.getString("synced_folder_uri", null)
|
||||||
if (single != null) folders.add(single)
|
if (single != null) folders.add(Pair(single, FileType.entries.toSet()))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (folders.isEmpty()) {
|
if (folders.isEmpty()) {
|
||||||
|
|
@ -83,8 +94,8 @@ class FolderSyncWorker(
|
||||||
syncMutex.withLock {
|
syncMutex.withLock {
|
||||||
var allSuccess = true
|
var allSuccess = true
|
||||||
|
|
||||||
for (uriString in folders) {
|
for ((uriString, allowedTypes) in folders) {
|
||||||
val success = performSyncForFolder(uriString, isMetadataOnly)
|
val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
|
||||||
if (!success) allSuccess = false
|
if (!success) allSuccess = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,7 +115,7 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun performSyncForFolder(folderUriString: String, metadataOnly: Boolean): Boolean {
|
private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set<FileType>, metadataOnly: Boolean): Boolean {
|
||||||
if (folderUriString.isBlank()) return true
|
if (folderUriString.isBlank()) return true
|
||||||
val folderUri = folderUriString.toUri()
|
val folderUri = folderUriString.toUri()
|
||||||
|
|
||||||
|
|
@ -195,7 +206,8 @@ class FolderSyncWorker(
|
||||||
file.listFiles().let { fileQueue.addAll(it) }
|
file.listFiles().let { fileQueue.addAll(it) }
|
||||||
} else if (file.isFile) {
|
} else if (file.isFile) {
|
||||||
val name = file.name ?: ""
|
val name = file.name ?: ""
|
||||||
if (isValidExtension(name) && !name.endsWith(".json") && !name.startsWith(".")) {
|
val type = getFileType(name, file.type)
|
||||||
|
if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) {
|
||||||
currentDiskFiles.add(file)
|
currentDiskFiles.add(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -298,14 +310,6 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isValidExtension(name: String): Boolean {
|
|
||||||
return name.endsWith(".pdf", true) ||
|
|
||||||
name.endsWith(".epub", true) ||
|
|
||||||
name.endsWith(".mobi", true) ||
|
|
||||||
name.endsWith(".azw3", true) ||
|
|
||||||
name.endsWith(".md", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getFileType(name: String, mimeType: String?): FileType? {
|
private fun getFileType(name: String, mimeType: String?): FileType? {
|
||||||
return when {
|
return when {
|
||||||
mimeType == "application/pdf" || name.endsWith(".pdf", true) -> FileType.PDF
|
mimeType == "application/pdf" || name.endsWith(".pdf", true) -> FileType.PDF
|
||||||
|
|
@ -313,6 +317,7 @@ class FolderSyncWorker(
|
||||||
name.endsWith(".mobi", true) || name.endsWith(".azw3", true) -> FileType.MOBI
|
name.endsWith(".mobi", true) || name.endsWith(".azw3", true) -> FileType.MOBI
|
||||||
name.endsWith(".md", true) -> FileType.MD
|
name.endsWith(".md", true) -> FileType.MD
|
||||||
name.endsWith(".txt", true) -> FileType.TXT
|
name.endsWith(".txt", true) -> FileType.TXT
|
||||||
|
name.endsWith(".html", true) || name.endsWith(".xhtml", true) || name.endsWith(".htm", true) -> FileType.HTML
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,10 @@ import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Folder
|
import androidx.compose.material.icons.filled.Folder
|
||||||
import androidx.compose.material.icons.filled.FolderSpecial
|
import androidx.compose.material.icons.filled.FolderSpecial
|
||||||
|
import androidx.compose.material.icons.filled.FormatListNumbered
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
|
@ -157,6 +159,8 @@ fun HomeScreen(
|
||||||
var showUpgradeDialog by remember { mutableStateOf(false) }
|
var showUpgradeDialog by remember { mutableStateOf(false) }
|
||||||
var showSignOutConfirmDialog by remember { mutableStateOf(false) }
|
var showSignOutConfirmDialog by remember { mutableStateOf(false) }
|
||||||
var showAboutDialog by remember { mutableStateOf(false) }
|
var showAboutDialog by remember { mutableStateOf(false) }
|
||||||
|
var showInfoDialog by remember { mutableStateOf(false) }
|
||||||
|
var itemForInfoDialog by remember { mutableStateOf<RecentFileItem?>(null) }
|
||||||
|
|
||||||
var showClearBookCacheDialog by remember { mutableStateOf(false) }
|
var showClearBookCacheDialog by remember { mutableStateOf(false) }
|
||||||
var showClearReflowCacheDialog by remember { mutableStateOf(false) }
|
var showClearReflowCacheDialog by remember { mutableStateOf(false) }
|
||||||
|
|
@ -304,12 +308,19 @@ fun HomeScreen(
|
||||||
},
|
},
|
||||||
onShowDeviceManagement = viewModel::showDeviceManagementForDebug,
|
onShowDeviceManagement = viewModel::showDeviceManagementForDebug,
|
||||||
onFolderSyncToggle = viewModel::setFolderSyncEnabled,
|
onFolderSyncToggle = viewModel::setFolderSyncEnabled,
|
||||||
onClearReflowCache = { showClearReflowCacheDialog = true }
|
onClearReflowCache = { showClearReflowCacheDialog = true },
|
||||||
|
onRecentFilesLimitChange = viewModel::setRecentFilesLimit
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ContextualTopAppBar(
|
ContextualTopAppBar(
|
||||||
selectedItemCount = selectedContextItems.size,
|
selectedItemCount = selectedContextItems.size,
|
||||||
onNavIconClick = { viewModel.clearContextualAction() },
|
onNavIconClick = { viewModel.clearContextualAction() },
|
||||||
|
onInfoClick = {
|
||||||
|
if (selectedContextItems.size == 1) {
|
||||||
|
itemForInfoDialog = selectedContextItems.first()
|
||||||
|
showInfoDialog = true
|
||||||
|
}
|
||||||
|
},
|
||||||
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
|
onPinClick = { viewModel.togglePinForContextualItems(isHome = true) },
|
||||||
onDeleteClick = { showDeleteConfirmDialog = true },
|
onDeleteClick = { showDeleteConfirmDialog = true },
|
||||||
onSelectAllClick = { viewModel.selectAllRecentFiles() })
|
onSelectAllClick = { viewModel.selectAllRecentFiles() })
|
||||||
|
|
@ -407,6 +418,21 @@ fun HomeScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
itemForInfoDialog?.let { item ->
|
||||||
|
if (showInfoDialog) {
|
||||||
|
FileInfoDialog(
|
||||||
|
item = item,
|
||||||
|
onDismiss = {
|
||||||
|
showInfoDialog = false
|
||||||
|
itemForInfoDialog = null
|
||||||
|
},
|
||||||
|
onUpdateName = { newName ->
|
||||||
|
viewModel.updateCustomName(item.bookId, newName)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showClearReflowCacheDialog) {
|
if (showClearReflowCacheDialog) {
|
||||||
DangerousFolderActionDialog(
|
DangerousFolderActionDialog(
|
||||||
title = "Clear Reflow Cache",
|
title = "Clear Reflow Cache",
|
||||||
|
|
@ -680,7 +706,7 @@ fun RecentFileCard(
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if ((item.type == FileType.EPUB || item.type == FileType.MOBI) && !item.title.isNullOrBlank()) {
|
text = item.customName ?: if ((item.type == FileType.EPUB || item.type == FileType.MOBI) && !item.title.isNullOrBlank()) {
|
||||||
item.title
|
item.title
|
||||||
} else {
|
} else {
|
||||||
item.displayName
|
item.displayName
|
||||||
|
|
@ -719,9 +745,11 @@ fun DefaultTopAppBar(
|
||||||
onDrawerClick: () -> Unit,
|
onDrawerClick: () -> Unit,
|
||||||
onAboutClick: () -> Unit,
|
onAboutClick: () -> Unit,
|
||||||
onShowDeviceManagement: () -> Unit,
|
onShowDeviceManagement: () -> Unit,
|
||||||
onFolderSyncToggle: (Boolean) -> Unit
|
onFolderSyncToggle: (Boolean) -> Unit,
|
||||||
|
onRecentFilesLimitChange: (Int) -> Unit
|
||||||
) {
|
) {
|
||||||
var showOptionsMenu by remember { mutableStateOf(false) }
|
var showOptionsMenu by remember { mutableStateOf(false) }
|
||||||
|
var showLimitMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CustomTopAppBar(title = { }, navigationIcon = {
|
CustomTopAppBar(title = { }, navigationIcon = {
|
||||||
IconButton(onClick = onDrawerClick) {
|
IconButton(onClick = onDrawerClick) {
|
||||||
|
|
@ -735,6 +763,30 @@ fun DefaultTopAppBar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, actions = {
|
}, actions = {
|
||||||
|
// Recent Files Limit Menu
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { showLimitMenu = true }) {
|
||||||
|
Icon(Icons.Default.FormatListNumbered, contentDescription = "Recent Files Limit")
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showLimitMenu, onDismissRequest = { showLimitMenu = false }
|
||||||
|
) {
|
||||||
|
val limitOptions = listOf(0, 10, 20, 50, 100)
|
||||||
|
limitOptions.forEach { limit ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(if (limit == 0) "No limit" else "$limit files") },
|
||||||
|
onClick = {
|
||||||
|
onRecentFilesLimitChange(limit)
|
||||||
|
showLimitMenu = false
|
||||||
|
},
|
||||||
|
trailingIcon = if (uiState.recentFilesLimit == limit) {
|
||||||
|
{ Icon(Icons.Default.Check, contentDescription = "Selected") }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Options Menu (MoreVert)
|
// Options Menu (MoreVert)
|
||||||
Box {
|
Box {
|
||||||
IconButton(onClick = { showOptionsMenu = true }) {
|
IconButton(onClick = { showOptionsMenu = true }) {
|
||||||
|
|
@ -964,7 +1016,6 @@ private fun AppDrawerContent(
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!isOss) {
|
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -973,16 +1024,12 @@ private fun AppDrawerContent(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
label = { Text("Help & Feedback") },
|
label = { Text("Help & Feedback") },
|
||||||
badge = {
|
|
||||||
if (uiState.hasUnreadFeedback) {
|
|
||||||
Badge()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selected = false,
|
selected = false,
|
||||||
onClick = { navController.navigate("feedback_screen_route") },
|
onClick = { navController.navigate("feedback_screen_route") },
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!isOss) {
|
||||||
if (uiState.currentUser != null) {
|
if (uiState.currentUser != null) {
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
icon = {
|
icon = {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.material.icons.filled.PushPin
|
import androidx.compose.material.icons.filled.PushPin
|
||||||
import androidx.compose.material.icons.filled.FilterList
|
import androidx.compose.material.icons.filled.FilterList
|
||||||
|
|
@ -270,6 +271,7 @@ fun LibraryScreen(
|
||||||
onScanNowClick = viewModel::scanSyncedFolder,
|
onScanNowClick = viewModel::scanSyncedFolder,
|
||||||
onSyncMetadataClick = viewModel::syncFolderMetadata,
|
onSyncMetadataClick = viewModel::syncFolderMetadata,
|
||||||
onSelectSyncFolderClick = onSelectSyncFolderClick,
|
onSelectSyncFolderClick = onSelectSyncFolderClick,
|
||||||
|
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
|
||||||
syncedFolders = uiState.syncedFolders,
|
syncedFolders = uiState.syncedFolders,
|
||||||
onAddFolderClick = { uri -> viewModel.addSyncedFolder(uri) },
|
onAddFolderClick = { uri -> viewModel.addSyncedFolder(uri) },
|
||||||
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
|
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
|
||||||
|
|
@ -328,6 +330,9 @@ fun LibraryScreen(
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
showInfoDialog = false
|
showInfoDialog = false
|
||||||
itemForInfoDialog = null
|
itemForInfoDialog = null
|
||||||
|
},
|
||||||
|
onUpdateName = { newName ->
|
||||||
|
viewModel.updateCustomName(item.bookId, newName)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -439,6 +444,9 @@ fun ShelfScreen(
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
showInfoDialog = false
|
showInfoDialog = false
|
||||||
itemForInfoDialog = null
|
itemForInfoDialog = null
|
||||||
|
},
|
||||||
|
onUpdateName = { newName ->
|
||||||
|
viewModel.updateCustomName(item.bookId, newName)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -484,6 +492,7 @@ fun LibraryScreenContent(
|
||||||
onScanNowClick: () -> Unit,
|
onScanNowClick: () -> Unit,
|
||||||
onSyncMetadataClick: () -> Unit,
|
onSyncMetadataClick: () -> Unit,
|
||||||
onSelectSyncFolderClick: () -> Unit,
|
onSelectSyncFolderClick: () -> Unit,
|
||||||
|
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
|
||||||
onDisconnectSyncFolderClick: () -> Unit,
|
onDisconnectSyncFolderClick: () -> Unit,
|
||||||
downloadingBookIds: Set<String>,
|
downloadingBookIds: Set<String>,
|
||||||
lastFolderScanTime: Long?,
|
lastFolderScanTime: Long?,
|
||||||
|
|
@ -750,8 +759,10 @@ fun LibraryScreenContent(
|
||||||
2 -> {
|
2 -> {
|
||||||
FolderSyncScreen(
|
FolderSyncScreen(
|
||||||
syncedFolders = syncedFolders,
|
syncedFolders = syncedFolders,
|
||||||
|
allRecentFiles = recentFiles,
|
||||||
onAddFolderClick = onAddFolderClick,
|
onAddFolderClick = onAddFolderClick,
|
||||||
onRemoveFolderClick = onRemoveFolderClick,
|
onRemoveFolderClick = onRemoveFolderClick,
|
||||||
|
onEditFolderFiltersClick = onEditFolderFiltersClick,
|
||||||
onScanNowClick = onScanNowClick,
|
onScanNowClick = onScanNowClick,
|
||||||
onSyncMetadataClick = onSyncMetadataClick,
|
onSyncMetadataClick = onSyncMetadataClick,
|
||||||
isLoading = isLoading || isRefreshing
|
isLoading = isLoading || isRefreshing
|
||||||
|
|
@ -1302,7 +1313,7 @@ private fun LibraryListItem(
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = item.title ?: item.displayName,
|
text = item.customName ?: item.title ?: item.displayName,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
|
|
@ -1458,12 +1469,16 @@ private fun DeleteShelvesConfirmationDialog(
|
||||||
@Composable
|
@Composable
|
||||||
private fun FolderSyncScreen(
|
private fun FolderSyncScreen(
|
||||||
syncedFolders: List<SyncedFolder>,
|
syncedFolders: List<SyncedFolder>,
|
||||||
|
allRecentFiles: List<RecentFileItem>,
|
||||||
onAddFolderClick: (android.net.Uri) -> Unit,
|
onAddFolderClick: (android.net.Uri) -> Unit,
|
||||||
onRemoveFolderClick: (SyncedFolder) -> Unit,
|
onRemoveFolderClick: (SyncedFolder) -> Unit,
|
||||||
|
onEditFolderFiltersClick: (SyncedFolder, Set<FileType>) -> Unit,
|
||||||
onScanNowClick: () -> Unit,
|
onScanNowClick: () -> Unit,
|
||||||
onSyncMetadataClick: () -> Unit,
|
onSyncMetadataClick: () -> Unit,
|
||||||
isLoading: Boolean
|
isLoading: Boolean
|
||||||
) {
|
) {
|
||||||
|
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
|
||||||
|
|
||||||
val pickFolderLauncher = rememberLauncherForActivityResult(
|
val pickFolderLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocumentTree()
|
contract = ActivityResultContracts.OpenDocumentTree()
|
||||||
) { uri ->
|
) { uri ->
|
||||||
|
|
@ -1472,8 +1487,6 @@ private fun FolderSyncScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalContext.current
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
if (syncedFolders.size < 3) {
|
if (syncedFolders.size < 3) {
|
||||||
|
|
@ -1492,7 +1505,6 @@ private fun FolderSyncScreen(
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// Global Actions Header
|
|
||||||
if (syncedFolders.isNotEmpty()) {
|
if (syncedFolders.isNotEmpty()) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -1534,28 +1546,54 @@ private fun FolderSyncScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List of Folders
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
contentPadding = PaddingValues(bottom = 80.dp) // Space for FAB
|
contentPadding = PaddingValues(bottom = 80.dp)
|
||||||
) {
|
) {
|
||||||
items(syncedFolders, key = { it.uriString }) { folder ->
|
items(syncedFolders, key = { it.uriString }) { folder ->
|
||||||
FolderCard(folder, onRemoveFolderClick)
|
FolderCard(
|
||||||
|
folder = folder,
|
||||||
|
allRecentFiles = allRecentFiles,
|
||||||
|
onRemoveClick = onRemoveFolderClick,
|
||||||
|
onEditFiltersClick = { editingFolder = folder }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editingFolder != null) {
|
||||||
|
EditFolderFiltersDialog(
|
||||||
|
folder = editingFolder!!,
|
||||||
|
onConfirm = { newFilters ->
|
||||||
|
onEditFolderFiltersClick(editingFolder!!, newFilters)
|
||||||
|
editingFolder = null
|
||||||
|
},
|
||||||
|
onDismiss = { editingFolder = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun FolderCard(
|
private fun FolderCard(
|
||||||
folder: SyncedFolder,
|
folder: SyncedFolder,
|
||||||
onRemoveClick: (SyncedFolder) -> Unit
|
allRecentFiles: List<RecentFileItem>,
|
||||||
|
onRemoveClick: (SyncedFolder) -> Unit,
|
||||||
|
onEditFiltersClick: (SyncedFolder) -> Unit
|
||||||
) {
|
) {
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
|
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
|
||||||
val lastScanText = if (folder.lastScanTime == 0L) "Never" else dateFormat.format(Date(folder.lastScanTime))
|
val lastScanText = if (folder.lastScanTime == 0L) "Never" else dateFormat.format(Date(folder.lastScanTime))
|
||||||
|
|
||||||
|
val folderFiles = remember(allRecentFiles, folder.uriString) {
|
||||||
|
allRecentFiles.filter { it.sourceFolderUri == folder.uriString }
|
||||||
|
}
|
||||||
|
val totalBooks = folderFiles.size
|
||||||
|
val countsByType = remember(folderFiles) {
|
||||||
|
folderFiles.groupBy { it.type }.mapValues { it.value.size }
|
||||||
|
}
|
||||||
|
|
||||||
androidx.compose.material3.ElevatedCard(
|
androidx.compose.material3.ElevatedCard(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
|
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
|
||||||
|
|
@ -1589,6 +1627,13 @@ private fun FolderCard(
|
||||||
Icon(Icons.Default.MoreVert, "Options")
|
Icon(Icons.Default.MoreVert, "Options")
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
|
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Edit Filters") },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onEditFiltersClick(folder)
|
||||||
|
}
|
||||||
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Remove Folder") },
|
text = { Text("Remove Folder") },
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
@ -1605,7 +1650,6 @@ private fun FolderCard(
|
||||||
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
|
||||||
|
|
||||||
// Details
|
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -1617,23 +1661,29 @@ private fun FolderCard(
|
||||||
Text(text = lastScanText, style = MaterialTheme.typography.bodySmall)
|
Text(text = lastScanText, style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
|
|
||||||
// You could add Book Count here if we queried it from DB
|
|
||||||
// For now, let's just show Status
|
|
||||||
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) {
|
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) {
|
||||||
Text(
|
Text(
|
||||||
text = "STATUS",
|
text = "BOOKS",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Text(text = totalBooks.toString(), style = MaterialTheme.typography.bodyMedium)
|
||||||
Box(
|
}
|
||||||
modifier = Modifier
|
}
|
||||||
.size(6.dp)
|
|
||||||
.background(Color(0xFF4CAF50), androidx.compose.foundation.shape.CircleShape)
|
if (countsByType.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
androidx.compose.foundation.layout.FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
countsByType.forEach { (type, count) ->
|
||||||
|
AssistChip(
|
||||||
|
onClick = { },
|
||||||
|
label = { Text("${type.name}: $count") }
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(text = "Active", style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1641,6 +1691,58 @@ private fun FolderCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EditFolderFiltersDialog(
|
||||||
|
folder: SyncedFolder,
|
||||||
|
onConfirm: (Set<FileType>) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
var selectedTypes by remember { mutableStateOf(folder.allowedFileTypes) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Filter File Types") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Select the file types you want to sync from this folder:",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
FileType.entries.forEach { type ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
selectedTypes = if (type in selectedTypes) selectedTypes - type else selectedTypes + type
|
||||||
|
}
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Checkbox(
|
||||||
|
checked = type in selectedTypes,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
selectedTypes = if (checked) selectedTypes + type else selectedTypes - type
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(type.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onConfirm(selectedTypes) },
|
||||||
|
enabled = selectedTypes.isNotEmpty()
|
||||||
|
) { Text("Save") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryFilterSheet(
|
fun LibraryFilterSheet(
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ data class DeviceLimitReachedState(
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SyncedFolder(
|
data class SyncedFolder(
|
||||||
val uriString: String, val name: String, val lastScanTime: Long
|
val uriString: String, val name: String, val lastScanTime: Long, val allowedFileTypes: Set<FileType> = FileType.entries.toSet()
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Shelf(val name: String, val books: List<RecentFileItem>) {
|
data class Shelf(val name: String, val books: List<RecentFileItem>) {
|
||||||
|
|
@ -227,6 +227,7 @@ data class ReaderScreenState(
|
||||||
val pinnedHomeBookIds: Set<String> = emptySet(),
|
val pinnedHomeBookIds: Set<String> = emptySet(),
|
||||||
val pinnedLibraryBookIds: Set<String> = emptySet(),
|
val pinnedLibraryBookIds: Set<String> = emptySet(),
|
||||||
val libraryFilters: LibraryFilters = LibraryFilters(),
|
val libraryFilters: LibraryFilters = LibraryFilters(),
|
||||||
|
val recentFilesLimit: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
open class MainViewModel(application: Application) : AndroidViewModel(application) {
|
open class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
@ -324,7 +325,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
)
|
)
|
||||||
else null,
|
else null,
|
||||||
pinnedHomeBookIds = prefs.getStringSet(KEY_PINNED_HOME, emptySet()) ?: emptySet(),
|
pinnedHomeBookIds = prefs.getStringSet(KEY_PINNED_HOME, emptySet()) ?: emptySet(),
|
||||||
pinnedLibraryBookIds = prefs.getStringSet(KEY_PINNED_LIBRARY, emptySet()) ?: emptySet()
|
pinnedLibraryBookIds = prefs.getStringSet(KEY_PINNED_LIBRARY, emptySet()) ?: emptySet(),
|
||||||
|
recentFilesLimit = prefs.getInt(KEY_RECENT_FILES_LIMIT, 0)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -377,7 +379,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val visibleRecentFiles = sortFiles(baseVisibleFiles.filter { it.isRecent }).let { list ->
|
val visibleRecentFiles = sortFiles(baseVisibleFiles.filter { it.isRecent }).let { list ->
|
||||||
val pinned = list.filter { it.bookId in internalState.pinnedHomeBookIds }
|
val pinned = list.filter { it.bookId in internalState.pinnedHomeBookIds }
|
||||||
val unpinned = list.filter { it.bookId !in internalState.pinnedHomeBookIds }
|
val unpinned = list.filter { it.bookId !in internalState.pinnedHomeBookIds }
|
||||||
pinned + unpinned
|
val combined = pinned + unpinned
|
||||||
|
if (internalState.recentFilesLimit > 0) combined.take(internalState.recentFilesLimit) else combined
|
||||||
}
|
}
|
||||||
|
|
||||||
val validContextualItems = internalState.contextualActionItems.filter { contextItem ->
|
val validContextualItems = internalState.contextualActionItems.filter { contextItem ->
|
||||||
|
|
@ -783,7 +786,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
fun deleteBookPermanently(bookId: String, onDeleted: () -> Unit = {}) {
|
fun deleteBookPermanently(bookId: String, onDeleted: () -> Unit = {}) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val item = recentFilesRepository.getFileByBookId(bookId) ?: return@launch
|
@Suppress("UnusedVariable", "Unused") val item = recentFilesRepository.getFileByBookId(bookId) ?: return@launch
|
||||||
|
|
||||||
Timber.d("Deleting book permanently from reader: $bookId")
|
Timber.d("Deleting book permanently from reader: $bookId")
|
||||||
|
|
||||||
|
|
@ -1384,7 +1387,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val oldTime = prefs.getLong(KEY_LAST_FOLDER_SCAN_TIME, 0L)
|
val oldTime = prefs.getLong(KEY_LAST_FOLDER_SCAN_TIME, 0L)
|
||||||
if (oldUri != null) {
|
if (oldUri != null) {
|
||||||
val name = getDisplayPathFromUri(appContext, oldUri)
|
val name = getDisplayPathFromUri(appContext, oldUri)
|
||||||
val migrated = SyncedFolder(oldUri, name, oldTime)
|
val migrated = SyncedFolder(oldUri, name, oldTime, FileType.entries.toSet())
|
||||||
folders.add(migrated)
|
folders.add(migrated)
|
||||||
saveSyncedFoldersToPrefs(folders)
|
saveSyncedFoldersToPrefs(folders)
|
||||||
|
|
||||||
|
|
@ -1398,11 +1401,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val jsonArray = JSONArray(jsonString)
|
val jsonArray = JSONArray(jsonString)
|
||||||
for (i in 0 until jsonArray.length()) {
|
for (i in 0 until jsonArray.length()) {
|
||||||
val obj = jsonArray.getJSONObject(i)
|
val obj = jsonArray.getJSONObject(i)
|
||||||
|
|
||||||
|
val allowedFileTypes = mutableSetOf<FileType>()
|
||||||
|
if (obj.has("allowedFileTypes")) {
|
||||||
|
val typesArray = obj.getJSONArray("allowedFileTypes")
|
||||||
|
for (j in 0 until typesArray.length()) {
|
||||||
|
try {
|
||||||
|
allowedFileTypes.add(FileType.valueOf(typesArray.getString(j)))
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
allowedFileTypes.addAll(FileType.entries)
|
||||||
|
}
|
||||||
|
|
||||||
folders.add(
|
folders.add(
|
||||||
SyncedFolder(
|
SyncedFolder(
|
||||||
uriString = obj.getString("uri"),
|
uriString = obj.getString("uri"),
|
||||||
name = obj.getString("name"),
|
name = obj.getString("name"),
|
||||||
lastScanTime = obj.optLong("lastScanTime", 0L)
|
lastScanTime = obj.optLong("lastScanTime", 0L),
|
||||||
|
allowedFileTypes = allowedFileTypes
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1420,6 +1437,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
obj.put("uri", folder.uriString)
|
obj.put("uri", folder.uriString)
|
||||||
obj.put("name", folder.name)
|
obj.put("name", folder.name)
|
||||||
obj.put("lastScanTime", folder.lastScanTime)
|
obj.put("lastScanTime", folder.lastScanTime)
|
||||||
|
val typesArray = JSONArray()
|
||||||
|
folder.allowedFileTypes.forEach { typesArray.put(it.name) }
|
||||||
|
obj.put("allowedFileTypes", typesArray)
|
||||||
jsonArray.put(obj)
|
jsonArray.put(obj)
|
||||||
}
|
}
|
||||||
prefs.edit { putString(KEY_SYNCED_FOLDERS_JSON, jsonArray.toString()) }
|
prefs.edit { putString(KEY_SYNCED_FOLDERS_JSON, jsonArray.toString()) }
|
||||||
|
|
@ -1446,7 +1466,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
)
|
)
|
||||||
|
|
||||||
val name = getDisplayPathFromUri(appContext, folderUri.toString())
|
val name = getDisplayPathFromUri(appContext, folderUri.toString())
|
||||||
val newFolder = SyncedFolder(folderUri.toString(), name, 0L)
|
val newFolder = SyncedFolder(folderUri.toString(), name, 0L, FileType.entries.toSet())
|
||||||
val newStats = currentFolders + newFolder
|
val newStats = currentFolders + newFolder
|
||||||
|
|
||||||
saveSyncedFoldersToPrefs(newStats)
|
saveSyncedFoldersToPrefs(newStats)
|
||||||
|
|
@ -1578,6 +1598,37 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateFolderFilters(folder: SyncedFolder, newFilters: Set<FileType>) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val currentFolders = _internalState.value.syncedFolders.toMutableList()
|
||||||
|
val index = currentFolders.indexOfFirst { it.uriString == folder.uriString }
|
||||||
|
if (index != -1) {
|
||||||
|
val updatedFolder = folder.copy(allowedFileTypes = newFilters)
|
||||||
|
currentFolders[index] = updatedFolder
|
||||||
|
saveSyncedFoldersToPrefs(currentFolders)
|
||||||
|
_internalState.update { it.copy(syncedFolders = currentFolders) }
|
||||||
|
|
||||||
|
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
|
||||||
|
.filter { it.type !in newFilters }
|
||||||
|
|
||||||
|
if (filesToRemove.isNotEmpty()) {
|
||||||
|
Timber.d("Removing ${filesToRemove.size} files that no longer match the filter for folder ${folder.name}")
|
||||||
|
val idsToRemove = filesToRemove.map { it.bookId }
|
||||||
|
|
||||||
|
idsToRemove.forEach { bookId ->
|
||||||
|
pdfTextRepository.clearBookText(bookId)
|
||||||
|
clearImportedFileCache(bookId)
|
||||||
|
}
|
||||||
|
recentFilesRepository.deleteFilePermanently(idsToRemove)
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
scanSyncedFolder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun disconnectAllSyncedFolders() {
|
fun disconnectAllSyncedFolders() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val folders = _internalState.value.syncedFolders
|
val folders = _internalState.value.syncedFolders
|
||||||
|
|
@ -2374,6 +2425,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setRecentFilesLimit(limit: Int) {
|
||||||
|
_internalState.update { it.copy(recentFilesLimit = limit) }
|
||||||
|
prefs.edit { putInt(KEY_RECENT_FILES_LIMIT, limit) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setSortOrder(sortOrder: SortOrder) {
|
fun setSortOrder(sortOrder: SortOrder) {
|
||||||
_internalState.update { it.copy(sortOrder = sortOrder) }
|
_internalState.update { it.copy(sortOrder = sortOrder) }
|
||||||
prefs.edit { putString(KEY_SORT_ORDER, sortOrder.name) }
|
prefs.edit { putString(KEY_SORT_ORDER, sortOrder.name) }
|
||||||
|
|
@ -3799,6 +3855,26 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateCustomName(bookId: String, newName: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val item = recentFilesRepository.getFileByBookId(bookId)
|
||||||
|
if (item != null) {
|
||||||
|
val updatedItem = item.copy(customName = newName, lastModifiedTimestamp = System.currentTimeMillis())
|
||||||
|
recentFilesRepository.addRecentFile(updatedItem)
|
||||||
|
|
||||||
|
if (uiState.value.isSyncEnabled) {
|
||||||
|
uploadSingleBookMetadata(updatedItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedItem.sourceFolderUri != null) {
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
recentFilesRepository.syncLocalMetadataToFolder(bookId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_SORT_ORDER = "sort_order"
|
private const val KEY_SORT_ORDER = "sort_order"
|
||||||
internal const val KEY_SHELVES = "shelf_names"
|
internal const val KEY_SHELVES = "shelf_names"
|
||||||
|
|
@ -3817,5 +3893,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
private const val MAX_FOLDER_LIMIT = 3
|
private const val MAX_FOLDER_LIMIT = 3
|
||||||
internal const val KEY_PINNED_HOME = "pinned_home_books"
|
internal const val KEY_PINNED_HOME = "pinned_home_books"
|
||||||
internal const val KEY_PINNED_LIBRARY = "pinned_library_books"
|
internal const val KEY_PINNED_LIBRARY = "pinned_library_books"
|
||||||
|
private const val KEY_RECENT_FILES_LIMIT = "recent_files_limit"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,80 +19,87 @@
|
||||||
*/
|
*/
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import timber.log.Timber
|
|
||||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.material3.LocalTextStyle
|
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.PushPin
|
||||||
|
import androidx.compose.material.icons.filled.SelectAll
|
||||||
import androidx.compose.material.icons.outlined.FileOpen
|
import androidx.compose.material.icons.outlined.FileOpen
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.ProvideTextStyle
|
import androidx.compose.material3.ProvideTextStyle
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.platform.UriHandler
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.aryan.reader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import timber.log.Timber
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import androidx.compose.foundation.text.ClickableText
|
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.text.withStyle
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.material.icons.filled.PushPin
|
|
||||||
import androidx.compose.material.icons.filled.SelectAll
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.platform.UriHandler
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
|
|
||||||
internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
|
internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
|
||||||
internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
|
internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
|
||||||
|
|
@ -314,60 +321,196 @@ fun DeleteConfirmationDialog(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit) {
|
fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (String?) -> Unit) {
|
||||||
|
LocalContext.current
|
||||||
|
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
|
val originalName = item.title ?: item.displayName
|
||||||
|
var editingName by remember { mutableStateOf(item.customName ?: originalName) }
|
||||||
|
val hasCustomName = item.customName != null
|
||||||
|
|
||||||
val formattedDate = remember(item.timestamp) {
|
val formattedDate = remember(item.timestamp) {
|
||||||
SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(item.timestamp))
|
SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(item.timestamp))
|
||||||
}
|
}
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("File Information") },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
val displayName = item.displayName
|
|
||||||
val epubTitle = item.title
|
|
||||||
|
|
||||||
if (item.type != FileType.PDF && !epubTitle.isNullOrBlank()) {
|
val pathText = remember(item.sourceFolderUri, item.displayName) {
|
||||||
InfoRow("Title:", epubTitle, maxLines = 3)
|
if (item.sourceFolderUri != null) {
|
||||||
if (displayName != epubTitle) {
|
try {
|
||||||
InfoRow("File Name:", displayName, maxLines = 2)
|
val uri = item.sourceFolderUri.toUri()
|
||||||
|
val docId = android.provider.DocumentsContract.getTreeDocumentId(uri)
|
||||||
|
val split = docId.split(":")
|
||||||
|
val storageName = if (split[0].equals("primary", ignoreCase = true)) "Internal storage" else split[0]
|
||||||
|
val relativePath = if (split.size > 1) {
|
||||||
|
Uri.decode(split[1]).removeSuffix("/")
|
||||||
|
} else ""
|
||||||
|
|
||||||
|
val leadingSlash = if (relativePath.isNotEmpty() && !relativePath.startsWith("/")) "/" else ""
|
||||||
|
|
||||||
|
"/$storageName$leadingSlash$relativePath/${item.displayName}"
|
||||||
|
} catch (_: Exception) {
|
||||||
|
val decoded = Uri.decode(item.sourceFolderUri)
|
||||||
|
if (decoded.contains("primary:")) {
|
||||||
|
"/Internal storage/${decoded.substringAfter("primary:").removeSuffix("/")}/${item.displayName}"
|
||||||
|
} else {
|
||||||
|
item.displayName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
InfoRow("File Name:", displayName, maxLines = 2)
|
"In-App Storage"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
InfoRow("Author:", it, maxLines = 2)
|
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
|
||||||
|
)
|
||||||
|
|
||||||
InfoRow("File Type:", item.type.name)
|
androidx.compose.material3.OutlinedTextField(
|
||||||
InfoRow("Date Added:", formattedDate)
|
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))
|
||||||
}
|
}
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text("OK") }
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (hasCustomName) {
|
||||||
|
Text(
|
||||||
|
text = "Original Name: $originalName",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
editingName = originalName
|
||||||
|
onUpdateName(null)
|
||||||
|
},
|
||||||
|
modifier = Modifier.align(Alignment.End),
|
||||||
|
contentPadding = PaddingValues(0.dp)
|
||||||
|
) {
|
||||||
|
Text("Revert to Original")
|
||||||
|
}
|
||||||
|
} else if (originalName != item.displayName) {
|
||||||
|
Text(
|
||||||
|
text = "File Name: ${item.displayName}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
|
||||||
|
InfoRowDetailed("Author", it)
|
||||||
|
}
|
||||||
|
InfoRowDetailed("Format", item.type.name)
|
||||||
|
InfoRowDetailed("Added", formattedDate)
|
||||||
|
InfoRowDetailed(
|
||||||
|
label = "Location",
|
||||||
|
value = pathText,
|
||||||
|
maxLines = 4,
|
||||||
|
onCopy = {
|
||||||
|
clipboardManager.setText(AnnotatedString(pathText))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
androidx.compose.material3.Button(onClick = {
|
||||||
|
val finalName = editingName.trim()
|
||||||
|
if (finalName != (item.customName ?: originalName)) {
|
||||||
|
if (finalName == originalName || finalName.isEmpty()) {
|
||||||
|
onUpdateName(null)
|
||||||
|
} else {
|
||||||
|
onUpdateName(finalName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDismiss()
|
||||||
|
}) { Text("Save") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InfoRow(label: String, value: String?, maxLines: Int = 1) {
|
private fun InfoRowDetailed(
|
||||||
if (value.isNullOrBlank()) return
|
label: String,
|
||||||
Row {
|
value: String,
|
||||||
|
maxLines: Int = 1,
|
||||||
|
onCopy: (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(90.dp)
|
.width(85.dp)
|
||||||
.padding(end = 8.dp)
|
.padding(top = 2.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = value,
|
text = value,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = maxLines,
|
maxLines = maxLines,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(top = 2.dp)
|
||||||
)
|
)
|
||||||
|
if (onCopy != null) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onCopy,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.padding(start = 4.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentCopy,
|
||||||
|
contentDescription = "Copy $label",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import androidx.room.TypeConverters
|
||||||
import androidx.room.migration.Migration
|
import androidx.room.migration.Migration
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
@Database(entities = [RecentFileEntity::class, CustomFontEntity::class], version = 13, exportSchema = false)
|
@Database(entities =[RecentFileEntity::class, CustomFontEntity::class], version = 14, exportSchema = false)
|
||||||
@TypeConverters(FileTypeConverter::class)
|
@TypeConverters(FileTypeConverter::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun recentFileDao(): RecentFileDao
|
abstract fun recentFileDao(): RecentFileDao
|
||||||
|
|
@ -173,6 +173,12 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_13_14 = object : Migration(13, 14) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE recent_files ADD COLUMN customName TEXT DEFAULT NULL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getDatabase(context: Context): AppDatabase {
|
fun getDatabase(context: Context): AppDatabase {
|
||||||
return INSTANCE ?: synchronized(this) {
|
return INSTANCE ?: synchronized(this) {
|
||||||
val instance = Room.databaseBuilder(
|
val instance = Room.databaseBuilder(
|
||||||
|
|
@ -184,7 +190,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
|
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
|
||||||
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
|
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
|
||||||
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
|
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
|
||||||
MIGRATION_12_13
|
MIGRATION_12_13, MIGRATION_13_14
|
||||||
)
|
)
|
||||||
.fallbackToDestructiveMigration(false)
|
.fallbackToDestructiveMigration(false)
|
||||||
.build()
|
.build()
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ data class FolderBookMetadata(
|
||||||
val lastModifiedTimestamp: Long,
|
val lastModifiedTimestamp: Long,
|
||||||
val bookmarksJson: String?,
|
val bookmarksJson: String?,
|
||||||
val locatorBlockIndex: Int?,
|
val locatorBlockIndex: Int?,
|
||||||
val locatorCharOffset: Int?
|
val locatorCharOffset: Int?,
|
||||||
|
val customName: String?
|
||||||
) {
|
) {
|
||||||
fun toJsonString(): String {
|
fun toJsonString(): String {
|
||||||
val json = JSONObject()
|
val json = JSONObject()
|
||||||
|
|
@ -36,6 +37,7 @@ data class FolderBookMetadata(
|
||||||
json.put("bookmarksJson", bookmarksJson)
|
json.put("bookmarksJson", bookmarksJson)
|
||||||
json.put("locatorBlockIndex", locatorBlockIndex ?: -1)
|
json.put("locatorBlockIndex", locatorBlockIndex ?: -1)
|
||||||
json.put("locatorCharOffset", locatorCharOffset ?: -1)
|
json.put("locatorCharOffset", locatorCharOffset ?: -1)
|
||||||
|
json.put("customName", customName)
|
||||||
return json.toString()
|
return json.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,7 +68,8 @@ data class FolderBookMetadata(
|
||||||
lastModifiedTimestamp = json.optLong("lastModifiedTimestamp", 0L),
|
lastModifiedTimestamp = json.optLong("lastModifiedTimestamp", 0L),
|
||||||
bookmarksJson = json.optStringNull("bookmarksJson"),
|
bookmarksJson = json.optStringNull("bookmarksJson"),
|
||||||
locatorBlockIndex = json.optIntNull("locatorBlockIndex"),
|
locatorBlockIndex = json.optIntNull("locatorBlockIndex"),
|
||||||
locatorCharOffset = json.optIntNull("locatorCharOffset")
|
locatorCharOffset = json.optIntNull("locatorCharOffset"),
|
||||||
|
customName = json.optStringNull("customName")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +96,7 @@ fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?,
|
||||||
lastModifiedTimestamp = this.lastModifiedTimestamp,
|
lastModifiedTimestamp = this.lastModifiedTimestamp,
|
||||||
isDeleted = false,
|
isDeleted = false,
|
||||||
bookmarksJson = this.bookmarksJson,
|
bookmarksJson = this.bookmarksJson,
|
||||||
sourceFolderUri = sourceFolderUri
|
sourceFolderUri = sourceFolderUri,
|
||||||
|
customName = this.customName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -48,5 +48,6 @@ data class RecentFileEntity(
|
||||||
val locatorCharOffset: Int?,
|
val locatorCharOffset: Int?,
|
||||||
val bookmarks: String?,
|
val bookmarks: String?,
|
||||||
@ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?,
|
@ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?,
|
||||||
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean
|
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
|
||||||
|
@ColumnInfo(defaultValue = "NULL") val customName: String?
|
||||||
)
|
)
|
||||||
|
|
@ -44,7 +44,8 @@ data class RecentFileItem(
|
||||||
val isDeleted: Boolean = false,
|
val isDeleted: Boolean = false,
|
||||||
val bookmarksJson: String? = null,
|
val bookmarksJson: String? = null,
|
||||||
val sourceFolderUri: String? = null,
|
val sourceFolderUri: String? = null,
|
||||||
val isReflowPreferred: Boolean = false
|
val isReflowPreferred: Boolean = false,
|
||||||
|
val customName: String? = null
|
||||||
) {
|
) {
|
||||||
fun getUri(): Uri? = uriString?.toUri()
|
fun getUri(): Uri? = uriString?.toUri()
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +72,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
|
||||||
isDeleted = this.isDeleted,
|
isDeleted = this.isDeleted,
|
||||||
bookmarksJson = this.bookmarks,
|
bookmarksJson = this.bookmarks,
|
||||||
sourceFolderUri = this.sourceFolderUri,
|
sourceFolderUri = this.sourceFolderUri,
|
||||||
isReflowPreferred = this.isReflowPreferred
|
isReflowPreferred = this.isReflowPreferred,
|
||||||
|
customName = this.customName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,7 +99,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
|
||||||
isDeleted = this.isDeleted,
|
isDeleted = this.isDeleted,
|
||||||
bookmarks = this.bookmarksJson,
|
bookmarks = this.bookmarksJson,
|
||||||
sourceFolderUri = this.sourceFolderUri,
|
sourceFolderUri = this.sourceFolderUri,
|
||||||
isReflowPreferred = this.isReflowPreferred
|
isReflowPreferred = this.isReflowPreferred,
|
||||||
|
customName = this.customName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,7 +121,8 @@ fun RecentFileItem.toBookMetadata(): BookMetadata {
|
||||||
isDeleted = this.isDeleted,
|
isDeleted = this.isDeleted,
|
||||||
lastModifiedTimestamp = this.lastModifiedTimestamp,
|
lastModifiedTimestamp = this.lastModifiedTimestamp,
|
||||||
bookmarksJson = this.bookmarksJson,
|
bookmarksJson = this.bookmarksJson,
|
||||||
hasAnnotations = false
|
hasAnnotations = false,
|
||||||
|
customName = this.customName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,6 +146,7 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem {
|
||||||
isAvailable = false,
|
isAvailable = false,
|
||||||
lastModifiedTimestamp = this.lastModifiedTimestamp,
|
lastModifiedTimestamp = this.lastModifiedTimestamp,
|
||||||
isDeleted = this.isDeleted,
|
isDeleted = this.isDeleted,
|
||||||
bookmarksJson = this.bookmarksJson
|
bookmarksJson = this.bookmarksJson,
|
||||||
|
customName = this.customName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -158,7 +158,8 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
lastModifiedTimestamp = entity.lastModifiedTimestamp,
|
lastModifiedTimestamp = entity.lastModifiedTimestamp,
|
||||||
bookmarksJson = entity.bookmarks,
|
bookmarksJson = entity.bookmarks,
|
||||||
locatorBlockIndex = entity.locatorBlockIndex,
|
locatorBlockIndex = entity.locatorBlockIndex,
|
||||||
locatorCharOffset = entity.locatorCharOffset
|
locatorCharOffset = entity.locatorCharOffset,
|
||||||
|
customName = entity.customName
|
||||||
)
|
)
|
||||||
|
|
||||||
LocalSyncUtils.saveMetadataToFolder(
|
LocalSyncUtils.saveMetadataToFolder(
|
||||||
|
|
|
||||||
|
|
@ -19,20 +19,8 @@
|
||||||
*/
|
*/
|
||||||
package com.aryan.reader.feedback
|
package com.aryan.reader.feedback
|
||||||
|
|
||||||
import android.net.Uri
|
import android.content.Intent
|
||||||
import timber.log.Timber
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.PickVisualMediaRequest
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -41,72 +29,34 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.outlined.Email
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.outlined.Feedback
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedCard
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Tab
|
|
||||||
import androidx.compose.material3.TabRow
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import coil.compose.SubcomposeAsyncImage
|
|
||||||
import coil.request.ImageRequest
|
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.data.FeedbackMessage
|
import timber.log.Timber
|
||||||
import com.aryan.reader.data.FeedbackThread
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Locale
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.compose.material.icons.filled.Email
|
|
||||||
import androidx.compose.material.icons.outlined.Email
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
|
|
||||||
|
|
||||||
private fun launchEmailFeedback(context: android.content.Context) {
|
private fun launchEmailFeedback(context: android.content.Context) {
|
||||||
val intent = Intent(Intent.ACTION_SENDTO).apply {
|
val intent = Intent(Intent.ACTION_SENDTO).apply {
|
||||||
|
|
@ -126,583 +76,135 @@ fun FeedbackScreen(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
viewModel: FeedbackViewModel = viewModel()
|
viewModel: FeedbackViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val uriHandler = LocalUriHandler.current
|
||||||
val activeThreadsCount = remember(uiState.threads) { uiState.threads.count { it.status == "open" } }
|
|
||||||
val isLimitReached = activeThreadsCount >= 3
|
|
||||||
|
|
||||||
// State for Tabs
|
|
||||||
var selectedTabIndex by remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
LaunchedEffect(uiState.errorMessage) {
|
|
||||||
uiState.errorMessage?.let {
|
|
||||||
snackbarHostState.showSnackbar(it)
|
|
||||||
viewModel.clearError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BackHandler(enabled = uiState.selectedThreadId != null) {
|
|
||||||
viewModel.onBackToThreadList()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to determine if current chat is closed
|
|
||||||
val currentThread = remember(uiState.selectedThreadId, uiState.threads) {
|
|
||||||
uiState.threads.find { it.id == uiState.selectedThreadId }
|
|
||||||
}
|
|
||||||
val isCurrentChatClosed = currentThread?.status == "closed"
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = { Text("Help & Feedback") },
|
||||||
Text(if (uiState.selectedThreadId == null) "Help & Feedback" else "Support Chat")
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
if (uiState.selectedThreadId != null) {
|
|
||||||
viewModel.onBackToThreadList()
|
|
||||||
} else {
|
|
||||||
navController.popBackStack()
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
if (uiState.selectedThreadId == null) {
|
|
||||||
IconButton(onClick = { launchEmailFeedback(context) }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Email,
|
|
||||||
contentDescription = "Send Email Feedback"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
|
||||||
floatingActionButton = {
|
|
||||||
if (uiState.selectedThreadId == null && selectedTabIndex == 0) {
|
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
|
||||||
ExtendedFloatingActionButton(
|
|
||||||
onClick = { if (!isLimitReached) viewModel.onStartCreateTicket() },
|
|
||||||
icon = { Icon(Icons.Default.Add, "New Ticket") },
|
|
||||||
text = { Text("New Ticket") },
|
|
||||||
containerColor = if (isLimitReached) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
contentColor = if (isLimitReached) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Box(modifier = Modifier.padding(paddingValues)) {
|
|
||||||
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") }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val displayedThreads = if (selectedTabIndex == 0) {
|
|
||||||
uiState.threads.filter { it.status == "open" }
|
|
||||||
} else {
|
|
||||||
uiState.threads.filter { it.status == "closed" }
|
|
||||||
}
|
|
||||||
|
|
||||||
ThreadList(
|
|
||||||
threads = displayedThreads,
|
|
||||||
onThreadClick = { viewModel.onThreadSelected(it.id) },
|
|
||||||
emptyMessage = if (selectedTabIndex == 0) "No active tickets" else "No closed tickets",
|
|
||||||
onEmailClick = { launchEmailFeedback(context) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} 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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
modifier = Modifier
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Text(
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
text = emptyMessage,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
Icon(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
imageVector = Icons.Outlined.Feedback,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(72.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Prefer email or can't sign in?",
|
text = "Get in Touch",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
color = MaterialTheme.colorScheme.outline
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
TextButton(onClick = onEmailClick) {
|
|
||||||
|
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(
|
Icon(
|
||||||
Icons.Default.Email,
|
painter = painterResource(id = R.drawable.github),
|
||||||
contentDescription = null,
|
contentDescription = "GitHub",
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(28.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
},
|
||||||
Text("Contact via Email")
|
onClick = {
|
||||||
|
uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme/issues")
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
launchEmailFeedback(context)
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
|
||||||
} else {
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
|
||||||
items(threads, key = { it.id }) { thread ->
|
|
||||||
ThreadItem(thread = thread, onClick = { onThreadClick(thread) })
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ThreadItem(
|
private fun FeedbackOptionCard(
|
||||||
thread: FeedbackThread,
|
title: String,
|
||||||
|
description: String,
|
||||||
|
icon: @Composable () -> Unit,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val dateStr = remember(thread.lastUpdated) {
|
OutlinedCard(
|
||||||
thread.lastUpdated?.let {
|
onClick = onClick,
|
||||||
SimpleDateFormat("MMM d", Locale.getDefault()).format(it)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
} ?: "Just now"
|
shape = RoundedCornerShape(16.dp)
|
||||||
}
|
|
||||||
|
|
||||||
val itemAlpha = if (thread.status == "closed") 0.6f else 1f
|
|
||||||
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.graphicsLayer { alpha = itemAlpha },
|
|
||||||
headlineContent = {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(thread.category, fontWeight = FontWeight.Bold)
|
|
||||||
if (thread.hasUnreadAdminReply) {
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.background(MaterialTheme.colorScheme.error, CircleShape)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
val isPreviewEmpty = thread.preview.isBlank()
|
|
||||||
val previewText = if (isPreviewEmpty) "Attached Image" else thread.preview
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = previewText,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
fontStyle = if (isPreviewEmpty) FontStyle.Italic else FontStyle.Normal
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Text(dateStr, style = MaterialTheme.typography.labelSmall)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatView(
|
|
||||||
messages: List<FeedbackMessage>,
|
|
||||||
pendingMessages: List<FeedbackMessage>,
|
|
||||||
inputMessage: String,
|
|
||||||
inputAttachments: List<Uri>,
|
|
||||||
onInputChange: (String) -> Unit,
|
|
||||||
onAttachmentsSelected: (List<Uri>) -> Unit,
|
|
||||||
onRemoveAttachment: (Uri) -> Unit,
|
|
||||||
onSend: () -> Unit,
|
|
||||||
isClosed: Boolean = false
|
|
||||||
) {
|
|
||||||
val imagePickerLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.PickMultipleVisualMedia(5),
|
|
||||||
onResult = { uris -> if (uris.isNotEmpty()) onAttachmentsSelected(uris) }
|
|
||||||
)
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
val displayedMessages = remember(messages, pendingMessages) {
|
|
||||||
val realIds = messages.map { it.id }.toSet()
|
|
||||||
val uniquePending = pendingMessages.filter { it.id !in realIds }
|
|
||||||
|
|
||||||
Timber.d("ChatView Recomposition: ${messages.size} real, ${uniquePending.size} pending unique")
|
|
||||||
|
|
||||||
messages + uniquePending
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
LaunchedEffect(displayedMessages.size) {
|
|
||||||
if (displayedMessages.isNotEmpty()) {
|
|
||||||
listState.animateScrollToItem(displayedMessages.size - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
contentPadding = PaddingValues(vertical = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
) {
|
||||||
items(displayedMessages, key = { it.id }) { msg ->
|
|
||||||
MessageBubble(
|
|
||||||
message = msg,
|
|
||||||
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
tonalElevation = 2.dp,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
if (isClosed) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(24.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "This ticket is closed.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (inputAttachments.isNotEmpty()) {
|
|
||||||
LazyRow(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 16.dp, top = 8.dp, end = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
items(inputAttachments) { uri ->
|
|
||||||
Box(modifier = Modifier.size(60.dp)) {
|
|
||||||
AsyncImage(
|
|
||||||
model = uri,
|
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier
|
|
||||||
.matchParentSize()
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Close,
|
|
||||||
contentDescription = "Remove",
|
|
||||||
modifier = Modifier
|
|
||||||
.size(20.dp)
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), CircleShape)
|
|
||||||
.padding(2.dp)
|
|
||||||
.clickable { onRemoveAttachment(uri) },
|
|
||||||
tint = androidx.compose.ui.graphics.Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(16.dp)
|
.fillMaxWidth()
|
||||||
.fillMaxWidth(),
|
.padding(20.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = { imagePickerLauncher.launch(PickVisualMediaRequest(
|
icon()
|
||||||
ActivityResultContracts.PickVisualMedia.ImageOnly)) }) {
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.image),
|
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
contentDescription = "Add Image",
|
contentDescription = "Open",
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = inputMessage,
|
|
||||||
onValueChange = onInputChange,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
placeholder = { Text("Type a message...") },
|
|
||||||
maxLines = 3,
|
|
||||||
shape = RoundedCornerShape(24.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
IconButton(
|
|
||||||
onClick = onSend,
|
|
||||||
enabled = inputMessage.isNotBlank() || inputAttachments.isNotEmpty()
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.Send,
|
|
||||||
contentDescription = "Send",
|
|
||||||
tint = if (inputMessage.isNotBlank() || inputAttachments.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
|
||||||
fun MessageBubble(
|
|
||||||
message: FeedbackMessage,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val isMe = message.sender == "user"
|
|
||||||
val alignment = if (isMe) Alignment.End else Alignment.Start
|
|
||||||
val color = if (isMe) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
val textColor = if (isMe) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
horizontalAlignment = alignment
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
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)
|
|
||||||
) {
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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,
|
var isDeleted: Boolean = false,
|
||||||
val lastModifiedTimestamp: Long = 0L,
|
val lastModifiedTimestamp: Long = 0L,
|
||||||
val bookmarksJson: String? = null,
|
val bookmarksJson: String? = null,
|
||||||
val hasAnnotations: Boolean = false
|
val hasAnnotations: Boolean = false,
|
||||||
|
val customName: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DeviceItem(
|
data class DeviceItem(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue