fixed bool deletion UI
This commit is contained in:
parent
8bb920caf8
commit
d749352333
@ -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,
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(
|
||||||
message,
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
.widthIn(max = 520.dp)
|
||||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
.padding(start = 16.dp, end = if (toast.actionLabel == null) 16.dp else 8.dp, top = 8.dp, bottom = 8.dp),
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.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
|
@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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
items = items.filterNot { it.fileId == item.fileId }
|
onDeleteRequested(
|
||||||
searchResults = searchResults.filterNot { it.fileId == item.fileId }
|
LibraryDeleteRequest(item.fileId, item.title),
|
||||||
coverCache.remove(item.fileId)
|
{
|
||||||
nextOffset = (nextOffset - 1).coerceAtLeast(items.size)
|
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 {
|
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 &&
|
||||||
|
|||||||
@ -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,15 +207,20 @@ internal fun BookView(
|
|||||||
ReadAloudPlatform.play()
|
ReadAloudPlatform.play()
|
||||||
},
|
},
|
||||||
onDelete = {
|
onDelete = {
|
||||||
scope.launch {
|
onDeleteRequested(
|
||||||
val result = deleteLibraryBook(fileId, book.title)
|
LibraryDeleteRequest(fileId, book.title),
|
||||||
if (result.deleted) {
|
{
|
||||||
saveActiveReadingFileId(null)
|
scope.launch {
|
||||||
onDeleted(result.message)
|
saveActiveReadingFileId(null)
|
||||||
} else {
|
}
|
||||||
showMessage(result.message)
|
onDeleted(null)
|
||||||
}
|
},
|
||||||
}
|
{
|
||||||
|
scope.launch {
|
||||||
|
saveActiveReadingFileId(fileId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onBack = {
|
onBack = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|||||||
@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user