From d749352333fc5468230cd5ae480390c6c3f68c37 Mon Sep 17 00:00:00 2001 From: sergeych Date: Fri, 22 May 2026 21:49:55 +0300 Subject: [PATCH] fixed bool deletion UI --- .../kotlin/net/sergeych/toread/App.kt | 157 ++++++++++++++++-- .../net/sergeych/toread/LibraryDelete.kt | 5 + .../net/sergeych/toread/LibraryScreen.kt | 53 ++++-- .../net/sergeych/toread/ReaderScreen.kt | 30 ++-- .../kotlin/net/sergeych/toread/Theme.kt | 6 + 5 files changed, 208 insertions(+), 43 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt index 2fe6352..732e78a 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt @@ -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(null) } + var toast by remember { mutableStateOf(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, ) { - Text( - message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.inverseOnSurface, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.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( + toast.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.inverseOnSurface, + 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.LoadingLibrary) } var activeScan by remember { mutableStateOf(null) } var scanJob by remember { mutableStateOf(null) } + var pendingDelete by remember { mutableStateOf(null) } + var pendingDeleteJob by remember { mutableStateOf(null) } + var hiddenDeletedFileIds by remember { mutableStateOf>(emptySet()) } + var nextDeleteId by remember { mutableStateOf(0L) } var imageViewer by remember { mutableStateOf(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( diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryDelete.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryDelete.kt index 68f5e5b..b30be4c 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryDelete.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryDelete.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt index 6896d68..d16c6c6 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt @@ -75,9 +75,15 @@ import kotlinx.coroutines.launch internal fun LibraryScreen( state: AppState.Library, activeScan: LibraryScanProgress?, + hiddenFileIds: Set, 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() } 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) { - items = items.filterNot { it.fileId == item.fileId } - searchResults = searchResults.filterNot { it.fileId == item.fileId } - coverCache.remove(item.fileId) - nextOffset = (nextOffset - 1).coerceAtLeast(items.size) + 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 && diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt index 7fc3e62..60a3b51 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt @@ -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,15 +207,20 @@ internal fun BookView( ReadAloudPlatform.play() }, onDelete = { - scope.launch { - val result = deleteLibraryBook(fileId, book.title) - if (result.deleted) { - saveActiveReadingFileId(null) - onDeleted(result.message) - } else { - showMessage(result.message) - } - } + onDeleteRequested( + LibraryDeleteRequest(fileId, book.title), + { + scope.launch { + saveActiveReadingFileId(null) + } + onDeleted(null) + }, + { + scope.launch { + saveActiveReadingFileId(fileId) + } + }, + ) }, onBack = { scope.launch { diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Theme.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Theme.kt index d27b12c..3c10ffd 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Theme.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Theme.kt @@ -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), )