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
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@ -24,13 +28,40 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
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
@Preview
fun App() {
var themeMode by remember { mutableStateOf(ThemeMode.SYSTEM) }
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()
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) {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
@ -46,10 +77,11 @@ fun App() {
themeMode = loadThemeMode()
}
LaunchedEffect(toastMessage) {
if (toastMessage != null) {
delay(1600)
toastMessage = null
LaunchedEffect(toast?.id) {
val current = toast ?: return@LaunchedEffect
delay(current.durationMillis)
if (toast?.id == current.id) {
toast = null
}
}
@ -60,42 +92,72 @@ fun App() {
onThemeToggle = {
val next = themeMode.next()
themeMode = next
toastMessage = "Theme: ${next.displayName}"
showToast("Theme: ${next.displayName}")
scope.launch {
saveThemeMode(next)
}
},
onShowToast = ::showToast,
)
}
AppToast(toastMessage, modifier = Modifier.align(Alignment.BottomCenter))
AppToast(toast, modifier = Modifier.align(Alignment.BottomCenter))
}
}
}
@Composable
private fun AppToast(message: String?, modifier: Modifier = Modifier) {
if (message != null) {
private fun AppToast(toast: AppToastData?, modifier: Modifier = Modifier) {
if (toast != null) {
Surface(
modifier = modifier.padding(bottom = 24.dp),
modifier = modifier.padding(horizontal = 16.dp, vertical = 24.dp),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.inverseSurface,
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(
message,
toast.message,
style = MaterialTheme.typography.bodyMedium,
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
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 activeScan by remember { mutableStateOf<LibraryScanProgress?>(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) }
val scope = rememberCoroutineScope()
@ -103,6 +165,64 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
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() {
imageViewer?.let {
imageViewer = null
@ -184,9 +304,11 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
is AppState.Library -> LibraryScreen(
state = current,
activeScan = activeScan,
hiddenFileIds = hiddenDeletedFileIds + pendingDelete?.request?.fileId?.let(::setOf).orEmpty(),
onStateChange = { state = it },
onNavigateToScan = { state = AppState.Scan(current.items, current.scanPath, current.message) },
onStartScan = ::startScan,
onDeleteRequested = ::requestDelete,
)
is AppState.Scan -> ScanScreen(
state = current,
@ -214,6 +336,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
onDeleted = { message ->
state = AppState.Library(emptyList(), current.scanPath, message)
},
onDeleteRequested = ::requestDelete,
onBack = ::navigateBack,
)
is AppState.BookInfo -> BookInfoScreen(

View File

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

View File

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

View File

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

View File

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