better library organization
This commit is contained in:
parent
06b4614cbe
commit
ddf68c1e6a
@ -17,12 +17,15 @@ 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.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
@ -90,6 +93,9 @@ internal fun LibraryScreen(
|
|||||||
var searchText by remember { mutableStateOf("") }
|
var searchText by remember { mutableStateOf("") }
|
||||||
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
|
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
|
||||||
var searching by remember { mutableStateOf(false) }
|
var searching by remember { mutableStateOf(false) }
|
||||||
|
var readingNowCollapsed by remember { mutableStateOf(false) }
|
||||||
|
var myLibraryCollapsed by remember { mutableStateOf(false) }
|
||||||
|
var notInterestedCollapsed by remember { mutableStateOf(false) }
|
||||||
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
|
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
|
||||||
val searchActive = searchText.isNotBlank()
|
val searchActive = searchText.isNotBlank()
|
||||||
val visibleItems = if (searchActive) searchResults else items
|
val visibleItems = if (searchActive) searchResults else items
|
||||||
@ -268,24 +274,7 @@ internal fun LibraryScreen(
|
|||||||
contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp),
|
contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
val hasReadingNow = !searchActive && visibleItems.firstOrNull()?.readingStatus == BookReadingStatus.READING
|
fun itemActions(item: LibraryItem) = LibraryItemActions(
|
||||||
if (hasReadingNow) {
|
|
||||||
item(key = "section-reading") {
|
|
||||||
LibrarySectionHeader("reading now")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
itemsIndexed(visibleItems, key = { _, item -> item.fileId }) { index, item ->
|
|
||||||
if (
|
|
||||||
hasReadingNow &&
|
|
||||||
item.readingStatus != BookReadingStatus.READING &&
|
|
||||||
(index == 0 || visibleItems[index - 1].readingStatus == BookReadingStatus.READING)
|
|
||||||
) {
|
|
||||||
LibrarySectionHeader("my library")
|
|
||||||
}
|
|
||||||
LibraryRow(
|
|
||||||
item = item,
|
|
||||||
coverCache = coverCache,
|
|
||||||
enabled = !busy,
|
|
||||||
onOpen = {
|
onOpen = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
busy = true
|
busy = true
|
||||||
@ -341,12 +330,27 @@ internal fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onRemoveMarks = {
|
||||||
|
scope.launch {
|
||||||
|
busy = true
|
||||||
|
try {
|
||||||
|
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) {
|
||||||
|
message = "Removed marks from ${item.title}."
|
||||||
|
refresh()
|
||||||
|
} else {
|
||||||
|
message = "Could not update ${item.title}."
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onNotInterested = {
|
onNotInterested = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
busy = true
|
busy = true
|
||||||
try {
|
try {
|
||||||
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) {
|
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) {
|
||||||
message = "Marked ${item.title} as not interesting."
|
message = "Marked ${item.title} as not interested."
|
||||||
refresh()
|
refresh()
|
||||||
} else {
|
} else {
|
||||||
message = "Could not update ${item.title}."
|
message = "Could not update ${item.title}."
|
||||||
@ -374,6 +378,52 @@ internal fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun LazyListScope.libraryRows(sectionKey: String, sectionItems: List<LibraryItem>) {
|
||||||
|
itemsIndexed(sectionItems, key = { _, item -> "$sectionKey-${item.fileId}" }) { _, item ->
|
||||||
|
LibraryRow(
|
||||||
|
item = item,
|
||||||
|
coverCache = coverCache,
|
||||||
|
enabled = !busy,
|
||||||
|
actions = itemActions(item),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchActive) {
|
||||||
|
libraryRows("search", visibleItems)
|
||||||
|
} else {
|
||||||
|
val readingNow = visibleItems.filter { it.readingStatus == BookReadingStatus.READING }
|
||||||
|
val myLibrary = visibleItems.filter { it.readingStatus != BookReadingStatus.READING && it.readingStatus != BookReadingStatus.NOT_INTERESTED }
|
||||||
|
val notInterested = visibleItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED }
|
||||||
|
|
||||||
|
librarySection(
|
||||||
|
key = "reading",
|
||||||
|
title = "reading now",
|
||||||
|
count = readingNow.size,
|
||||||
|
collapsed = readingNowCollapsed,
|
||||||
|
onCollapsedChange = { readingNowCollapsed = it },
|
||||||
|
) {
|
||||||
|
libraryRows("reading", readingNow)
|
||||||
|
}
|
||||||
|
librarySection(
|
||||||
|
key = "library",
|
||||||
|
title = "my library",
|
||||||
|
count = myLibrary.size,
|
||||||
|
collapsed = myLibraryCollapsed,
|
||||||
|
onCollapsedChange = { myLibraryCollapsed = it },
|
||||||
|
) {
|
||||||
|
libraryRows("library", myLibrary)
|
||||||
|
}
|
||||||
|
librarySection(
|
||||||
|
key = "not-interested",
|
||||||
|
title = "not interested",
|
||||||
|
count = notInterested.size,
|
||||||
|
collapsed = notInterestedCollapsed,
|
||||||
|
onCollapsedChange = { notInterestedCollapsed = it },
|
||||||
|
) {
|
||||||
|
libraryRows("not-interested", notInterested)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!searchActive && !endReached) {
|
if (!searchActive && !endReached) {
|
||||||
item(key = "load-more") {
|
item(key = "load-more") {
|
||||||
@ -482,15 +532,56 @@ private fun EmptySearchPane(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.librarySection(
|
||||||
|
key: String,
|
||||||
|
title: String,
|
||||||
|
count: Int,
|
||||||
|
collapsed: Boolean,
|
||||||
|
onCollapsedChange: (Boolean) -> Unit,
|
||||||
|
content: LazyListScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
if (count == 0) return
|
||||||
|
item(key = "section-$key") {
|
||||||
|
LibrarySectionHeader(
|
||||||
|
text = title,
|
||||||
|
count = count,
|
||||||
|
collapsed = collapsed,
|
||||||
|
onToggle = { onCollapsedChange(!collapsed) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!collapsed) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LibrarySectionHeader(text: String) {
|
private fun LibrarySectionHeader(
|
||||||
|
text: String,
|
||||||
|
count: Int,
|
||||||
|
collapsed: Boolean,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onToggle)
|
||||||
|
.padding(top = 10.dp, bottom = 4.dp, start = 8.dp, end = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (collapsed) Icons.AutoMirrored.Filled.KeyboardArrowRight else Icons.Filled.KeyboardArrowDown,
|
||||||
|
contentDescription = if (collapsed) "Expand $text" else "Collapse $text",
|
||||||
|
tint = MaterialTheme.colorScheme.outline,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text,
|
"$text ($count)",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.outline,
|
color = MaterialTheme.colorScheme.outline,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp),
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -528,11 +619,7 @@ private fun LibraryRow(
|
|||||||
item: LibraryItem,
|
item: LibraryItem,
|
||||||
coverCache: MutableMap<String, LibraryCover?>,
|
coverCache: MutableMap<String, LibraryCover?>,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
onOpen: () -> Unit,
|
actions: LibraryItemActions,
|
||||||
onMarkAsRead: () -> Unit,
|
|
||||||
onMarkAsUnread: () -> Unit,
|
|
||||||
onNotInterested: () -> Unit,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
var menuOpen by remember { mutableStateOf(false) }
|
var menuOpen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@ -540,7 +627,7 @@ private fun LibraryRow(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(enabled = enabled, onClick = onOpen)
|
.clickable(enabled = enabled, onClick = actions.onOpen)
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@ -575,7 +662,7 @@ private fun LibraryRow(
|
|||||||
text = { Text("Open") },
|
text = { Text("Open") },
|
||||||
onClick = {
|
onClick = {
|
||||||
menuOpen = false
|
menuOpen = false
|
||||||
onOpen()
|
actions.onOpen()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
@ -584,7 +671,7 @@ private fun LibraryRow(
|
|||||||
text = { Text("Mark as read") },
|
text = { Text("Mark as read") },
|
||||||
onClick = {
|
onClick = {
|
||||||
menuOpen = false
|
menuOpen = false
|
||||||
onMarkAsRead()
|
actions.onMarkAsRead()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -593,15 +680,24 @@ private fun LibraryRow(
|
|||||||
text = { Text("Mark as unread") },
|
text = { Text("Mark as unread") },
|
||||||
onClick = {
|
onClick = {
|
||||||
menuOpen = false
|
menuOpen = false
|
||||||
onMarkAsUnread()
|
actions.onMarkAsUnread()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.readingStatus != BookReadingStatus.NEW) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Remove marks") },
|
||||||
|
onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
actions.onRemoveMarks()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Not interesting") },
|
text = { Text("Not interested") },
|
||||||
onClick = {
|
onClick = {
|
||||||
menuOpen = false
|
menuOpen = false
|
||||||
onNotInterested()
|
actions.onNotInterested()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
@ -609,7 +705,7 @@ private fun LibraryRow(
|
|||||||
text = { Text("Delete") },
|
text = { Text("Delete") },
|
||||||
onClick = {
|
onClick = {
|
||||||
menuOpen = false
|
menuOpen = false
|
||||||
onDelete()
|
actions.onDelete()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -618,6 +714,15 @@ private fun LibraryRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class LibraryItemActions(
|
||||||
|
val onOpen: () -> Unit,
|
||||||
|
val onMarkAsRead: () -> Unit,
|
||||||
|
val onMarkAsUnread: () -> Unit,
|
||||||
|
val onRemoveMarks: () -> Unit,
|
||||||
|
val onNotInterested: () -> Unit,
|
||||||
|
val onDelete: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LibraryCover(
|
private fun LibraryCover(
|
||||||
item: LibraryItem,
|
item: LibraryItem,
|
||||||
|
|||||||
@ -698,7 +698,11 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
|
|||||||
LEFT JOIN books b ON b.id = f.book_id
|
LEFT JOIN books b ON b.id = f.book_id
|
||||||
WHERE f.duplicate_of_file_id IS NULL
|
WHERE f.duplicate_of_file_id IS NULL
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END,
|
CASE
|
||||||
|
WHEN f.reading_status = 'READING' THEN 0
|
||||||
|
WHEN f.reading_status = 'NOT_INTERESTED' THEN 2
|
||||||
|
ELSE 1
|
||||||
|
END,
|
||||||
CASE WHEN f.reading_status = 'READING' THEN f.last_read_at END DESC NULLS LAST,
|
CASE WHEN f.reading_status = 'READING' THEN f.last_read_at END DESC NULLS LAST,
|
||||||
LOWER(COALESCE(NULLIF(b.title, ''), NULLIF(f.original_filename, ''), f.id)),
|
LOWER(COALESCE(NULLIF(b.title, ''), NULLIF(f.original_filename, ''), f.id)),
|
||||||
f.id
|
f.id
|
||||||
|
|||||||
@ -158,8 +158,8 @@ class H2LibraryDatabaseTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun listsReadingBooksFirstThenSortsByTitle() {
|
fun listsReadingBooksFirstNotInterestedLastThenSortsByTitle() {
|
||||||
val db = H2LibraryDatabase.openMemory("listsReadingBooksFirstThenSortsByTitle")
|
val db = H2LibraryDatabase.openMemory("listsReadingBooksFirstNotInterestedLastThenSortsByTitle")
|
||||||
val now = 1_700_000_000_000L
|
val now = 1_700_000_000_000L
|
||||||
|
|
||||||
db.transaction {
|
db.transaction {
|
||||||
@ -168,6 +168,7 @@ class H2LibraryDatabaseTest {
|
|||||||
Triple("book-alpha", "Alpha", BookReadingStatus.READ),
|
Triple("book-alpha", "Alpha", BookReadingStatus.READ),
|
||||||
Triple("book-gamma", "Gamma", BookReadingStatus.READING),
|
Triple("book-gamma", "Gamma", BookReadingStatus.READING),
|
||||||
Triple("book-aardvark", "Aardvark", BookReadingStatus.READING),
|
Triple("book-aardvark", "Aardvark", BookReadingStatus.READING),
|
||||||
|
Triple("book-omega", "Omega", BookReadingStatus.NOT_INTERESTED),
|
||||||
).forEachIndexed { index, (bookId, title, status) ->
|
).forEachIndexed { index, (bookId, title, status) ->
|
||||||
books.upsert(
|
books.upsert(
|
||||||
BookRecord(
|
BookRecord(
|
||||||
@ -194,7 +195,7 @@ class H2LibraryDatabaseTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Aardvark", "Gamma", "Alpha", "Beta"),
|
listOf("Aardvark", "Gamma", "Alpha", "Beta", "Omega"),
|
||||||
db.files.listLibraryFiles().map { it.title },
|
db.files.listLibraryFiles().map { it.title },
|
||||||
)
|
)
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user