fixed bool deletion UI
This commit is contained in:
parent
8bb920caf8
commit
d749352333
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user