fixed bool deletion UI

This commit is contained in:
Sergey Chernov 2026-05-22 21:49:55 +03:00
parent 8bb920caf8
commit d749352333
5 changed files with 208 additions and 43 deletions

View File

@ -1,13 +1,17 @@
package net.sergeych.toread package net.sergeych.toread
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -24,13 +28,40 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val DefaultToastDurationMillis = 1_600L
private const val DeleteUndoDurationMillis = 5_000L
private data class AppToastData(
val id: Long,
val message: String,
val actionLabel: String? = null,
val onAction: (() -> Unit)? = null,
val durationMillis: Long = DefaultToastDurationMillis,
)
private data class PendingLibraryDelete(
val id: Long,
val request: LibraryDeleteRequest,
val restore: () -> Unit,
)
@Composable @Composable
@Preview @Preview
fun App() { fun App() {
var themeMode by remember { mutableStateOf(ThemeMode.SYSTEM) } var themeMode by remember { mutableStateOf(ThemeMode.SYSTEM) }
var systemDark by remember { mutableStateOf(isPlatformDarkTheme()) } var systemDark by remember { mutableStateOf(isPlatformDarkTheme()) }
var toastMessage by remember { mutableStateOf<String?>(null) } var toast by remember { mutableStateOf<AppToastData?>(null) }
var nextToastId by remember { mutableStateOf(0L) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
fun showToast(
message: String,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
durationMillis: Long = DefaultToastDurationMillis,
) {
nextToastId += 1
toast = AppToastData(nextToastId, message, actionLabel, onAction, durationMillis)
}
val useDark = when (themeMode) { val useDark = when (themeMode) {
ThemeMode.LIGHT -> false ThemeMode.LIGHT -> false
ThemeMode.DARK -> true ThemeMode.DARK -> true
@ -46,10 +77,11 @@ fun App() {
themeMode = loadThemeMode() themeMode = loadThemeMode()
} }
LaunchedEffect(toastMessage) { LaunchedEffect(toast?.id) {
if (toastMessage != null) { val current = toast ?: return@LaunchedEffect
delay(1600) delay(current.durationMillis)
toastMessage = null if (toast?.id == current.id) {
toast = null
} }
} }
@ -60,42 +92,72 @@ fun App() {
onThemeToggle = { onThemeToggle = {
val next = themeMode.next() val next = themeMode.next()
themeMode = next themeMode = next
toastMessage = "Theme: ${next.displayName}" showToast("Theme: ${next.displayName}")
scope.launch { scope.launch {
saveThemeMode(next) saveThemeMode(next)
} }
}, },
onShowToast = ::showToast,
) )
} }
AppToast(toastMessage, modifier = Modifier.align(Alignment.BottomCenter)) AppToast(toast, modifier = Modifier.align(Alignment.BottomCenter))
} }
} }
} }
@Composable @Composable
private fun AppToast(message: String?, modifier: Modifier = Modifier) { private fun AppToast(toast: AppToastData?, modifier: Modifier = Modifier) {
if (message != null) { if (toast != null) {
Surface( Surface(
modifier = modifier.padding(bottom = 24.dp), modifier = modifier.padding(horizontal = 16.dp, vertical = 24.dp),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.inverseSurface, color = MaterialTheme.colorScheme.inverseSurface,
tonalElevation = 6.dp, tonalElevation = 6.dp,
) {
Row(
modifier = Modifier
.widthIn(max = 520.dp)
.padding(start = 16.dp, end = if (toast.actionLabel == null) 16.dp else 8.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
message, toast.message,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.inverseOnSurface, color = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), modifier = Modifier.weight(1f),
) )
if (toast.actionLabel != null && toast.onAction != null) {
TextButton(
onClick = toast.onAction,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.inversePrimary,
),
) {
Text(toast.actionLabel)
}
}
}
} }
} }
} }
@Composable @Composable
private fun BookReaderApp(onThemeToggle: () -> Unit) { private fun BookReaderApp(
onThemeToggle: () -> Unit,
onShowToast: (
message: String,
actionLabel: String?,
onAction: (() -> Unit)?,
durationMillis: Long,
) -> Unit,
) {
var state by remember { mutableStateOf<AppState>(AppState.LoadingLibrary) } var state by remember { mutableStateOf<AppState>(AppState.LoadingLibrary) }
var activeScan by remember { mutableStateOf<LibraryScanProgress?>(null) } var activeScan by remember { mutableStateOf<LibraryScanProgress?>(null) }
var scanJob by remember { mutableStateOf<Job?>(null) } var scanJob by remember { mutableStateOf<Job?>(null) }
var pendingDelete by remember { mutableStateOf<PendingLibraryDelete?>(null) }
var pendingDeleteJob by remember { mutableStateOf<Job?>(null) }
var hiddenDeletedFileIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var nextDeleteId by remember { mutableStateOf(0L) }
var imageViewer by remember { mutableStateOf<ViewedBookImage?>(null) } var imageViewer by remember { mutableStateOf<ViewedBookImage?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -103,6 +165,64 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
state = loadStartupState() state = loadStartupState()
} }
fun commitPendingDelete(delete: PendingLibraryDelete) {
scope.launch {
val result = deleteLibraryBook(delete.request.fileId, delete.request.title)
if (!result.deleted) {
delete.restore()
onShowToast(result.message, null, null, DefaultToastDurationMillis)
} else {
hiddenDeletedFileIds = hiddenDeletedFileIds + delete.request.fileId
}
}
}
fun requestDelete(
request: LibraryDeleteRequest,
remove: () -> Unit,
restore: () -> Unit,
) {
pendingDeleteJob?.cancel()
pendingDelete?.let(::commitPendingDelete)
nextDeleteId += 1
val deleteId = nextDeleteId
val nextDelete = PendingLibraryDelete(deleteId, request, restore)
pendingDelete = nextDelete
hiddenDeletedFileIds = hiddenDeletedFileIds - request.fileId
remove()
pendingDeleteJob = scope.launch {
delay(DeleteUndoDurationMillis)
val result = deleteLibraryBook(request.fileId, request.title)
if (pendingDelete?.id == deleteId) {
pendingDelete = null
pendingDeleteJob = null
if (!result.deleted) {
restore()
onShowToast(result.message, null, null, DefaultToastDurationMillis)
} else {
hiddenDeletedFileIds = hiddenDeletedFileIds + request.fileId
}
}
}
onShowToast(
"Removed ${request.title}.",
"Undo",
{
if (pendingDelete?.id == deleteId) {
pendingDeleteJob?.cancel()
pendingDeleteJob = null
pendingDelete = null
restore()
onShowToast("Restored ${request.title}.", null, null, DefaultToastDurationMillis)
}
},
DeleteUndoDurationMillis,
)
}
fun navigateBack() { fun navigateBack() {
imageViewer?.let { imageViewer?.let {
imageViewer = null imageViewer = null
@ -184,9 +304,11 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
is AppState.Library -> LibraryScreen( is AppState.Library -> LibraryScreen(
state = current, state = current,
activeScan = activeScan, activeScan = activeScan,
hiddenFileIds = hiddenDeletedFileIds + pendingDelete?.request?.fileId?.let(::setOf).orEmpty(),
onStateChange = { state = it }, onStateChange = { state = it },
onNavigateToScan = { state = AppState.Scan(current.items, current.scanPath, current.message) }, onNavigateToScan = { state = AppState.Scan(current.items, current.scanPath, current.message) },
onStartScan = ::startScan, onStartScan = ::startScan,
onDeleteRequested = ::requestDelete,
) )
is AppState.Scan -> ScanScreen( is AppState.Scan -> ScanScreen(
state = current, state = current,
@ -214,6 +336,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
onDeleted = { message -> onDeleted = { message ->
state = AppState.Library(emptyList(), current.scanPath, message) state = AppState.Library(emptyList(), current.scanPath, message)
}, },
onDeleteRequested = ::requestDelete,
onBack = ::navigateBack, onBack = ::navigateBack,
) )
is AppState.BookInfo -> BookInfoScreen( is AppState.BookInfo -> BookInfoScreen(

View File

@ -1,5 +1,10 @@
package net.sergeych.toread package net.sergeych.toread
internal data class LibraryDeleteRequest(
val fileId: String,
val title: String,
)
internal data class LibraryDeleteResult( internal data class LibraryDeleteResult(
val deleted: Boolean, val deleted: Boolean,
val message: String, val message: String,

View File

@ -75,9 +75,15 @@ import kotlinx.coroutines.launch
internal fun LibraryScreen( internal fun LibraryScreen(
state: AppState.Library, state: AppState.Library,
activeScan: LibraryScanProgress?, activeScan: LibraryScanProgress?,
hiddenFileIds: Set<String>,
onStateChange: (AppState) -> Unit, onStateChange: (AppState) -> Unit,
onNavigateToScan: () -> Unit, onNavigateToScan: () -> Unit,
onStartScan: (String) -> Unit, onStartScan: (String) -> Unit,
onDeleteRequested: (
request: LibraryDeleteRequest,
remove: () -> Unit,
restore: () -> Unit,
) -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
@ -100,7 +106,9 @@ internal fun LibraryScreen(
var notInterestedCollapsed 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 libraryItems = items.filterNot { it.fileId in hiddenFileIds }
val visibleSearchResults = searchResults.filterNot { it.fileId in hiddenFileIds }
val visibleItems = if (searchActive) visibleSearchResults else libraryItems
suspend fun loadPage(reset: Boolean = false) { suspend fun loadPage(reset: Boolean = false) {
if (loadingPage) return if (loadingPage) return
@ -421,21 +429,32 @@ internal fun LibraryScreen(
} }
}, },
onDelete = { onDelete = {
scope.launch { val previousItems = items
busy = true val previousSearchResults = searchResults
try { val previousRecentlyAddedItems = recentlyAddedItems
val result = deleteLibraryBook(item.fileId, item.title) val previousCover = coverCache[item.fileId]
message = result.message val hadCover = coverCache.containsKey(item.fileId)
if (result.deleted) { val previousNextOffset = nextOffset
onDeleteRequested(
LibraryDeleteRequest(item.fileId, item.title),
{
message = null
items = items.filterNot { it.fileId == item.fileId } items = items.filterNot { it.fileId == item.fileId }
searchResults = searchResults.filterNot { it.fileId == item.fileId } searchResults = searchResults.filterNot { it.fileId == item.fileId }
recentlyAddedItems = recentlyAddedItems.filterNot { it.fileId == item.fileId }
coverCache.remove(item.fileId) coverCache.remove(item.fileId)
nextOffset = (nextOffset - 1).coerceAtLeast(items.size) nextOffset = (nextOffset - 1).coerceAtLeast(items.size)
},
{
items = previousItems
searchResults = previousSearchResults
recentlyAddedItems = previousRecentlyAddedItems
if (hadCover) {
coverCache[item.fileId] = previousCover
} }
} finally { nextOffset = previousNextOffset
busy = false },
} )
}
}, },
) )
@ -454,7 +473,9 @@ internal fun LibraryScreen(
libraryRows("search", visibleItems) libraryRows("search", visibleItems)
} else { } else {
val readingNow = visibleItems.filter { it.readingStatus == BookReadingStatus.READING } val readingNow = visibleItems.filter { it.readingStatus == BookReadingStatus.READING }
val recentlyAdded = recentlyAddedItems.filter { it.readingStatus == BookReadingStatus.NEW } val recentlyAdded = recentlyAddedItems.filter {
it.fileId !in hiddenFileIds && it.readingStatus == BookReadingStatus.NEW
}
val recentlyAddedIds = recentlyAdded.mapTo(mutableSetOf()) { it.fileId } val recentlyAddedIds = recentlyAdded.mapTo(mutableSetOf()) { it.fileId }
val myLibrary = visibleItems.filter { val myLibrary = visibleItems.filter {
it.fileId !in recentlyAddedIds && it.fileId !in recentlyAddedIds &&

View File

@ -66,7 +66,12 @@ internal fun BookView(
onImageOpen: (ViewedBookImage) -> Unit, onImageOpen: (ViewedBookImage) -> Unit,
onThemeToggle: () -> Unit, onThemeToggle: () -> Unit,
onBookInfo: () -> Unit, onBookInfo: () -> Unit,
onDeleted: (String) -> Unit, onDeleted: (String?) -> Unit,
onDeleteRequested: (
request: LibraryDeleteRequest,
remove: () -> Unit,
restore: () -> Unit,
) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val stats = remember(book) { BookStats.from(book) } val stats = remember(book) { BookStats.from(book) }
@ -202,16 +207,21 @@ internal fun BookView(
ReadAloudPlatform.play() ReadAloudPlatform.play()
}, },
onDelete = { onDelete = {
onDeleteRequested(
LibraryDeleteRequest(fileId, book.title),
{
scope.launch { scope.launch {
val result = deleteLibraryBook(fileId, book.title)
if (result.deleted) {
saveActiveReadingFileId(null) saveActiveReadingFileId(null)
onDeleted(result.message)
} else {
showMessage(result.message)
} }
onDeleted(null)
},
{
scope.launch {
saveActiveReadingFileId(fileId)
} }
}, },
)
},
onBack = { onBack = {
scope.launch { scope.launch {
saveLibraryReadingPosition( saveLibraryReadingPosition(

View File

@ -28,6 +28,9 @@ internal fun lightReaderColorScheme() = androidx.compose.material3.lightColorSch
surface = Color(0xFFFFFBF5), surface = Color(0xFFFFFBF5),
surfaceVariant = Color(0xFFE7DFD3), surfaceVariant = Color(0xFFE7DFD3),
onSurface = Color(0xFF24211D), onSurface = Color(0xFF24211D),
inverseSurface = Color(0xFF3A322C),
inverseOnSurface = Color(0xFFF7F2EA),
inversePrimary = Color(0xFFAFCFC4),
outline = Color(0xFF8C8174), outline = Color(0xFF8C8174),
) )
@ -43,5 +46,8 @@ internal fun darkReaderColorScheme() = androidx.compose.material3.darkColorSchem
surface = Color(0xFF211D19), surface = Color(0xFF211D19),
surfaceVariant = Color(0xFF4E463D), surfaceVariant = Color(0xFF4E463D),
onSurface = Color(0xFFECE0D4), onSurface = Color(0xFFECE0D4),
inverseSurface = Color(0xFFECE0D4),
inverseOnSurface = Color(0xFF24211D),
inversePrimary = Color(0xFF425D56),
outline = Color(0xFFA99E91), outline = Color(0xFFA99E91),
) )