diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt
index 3ae174da..112a2cfe 100644
--- a/buildSrc/src/main/kotlin/Config.kt
+++ b/buildSrc/src/main/kotlin/Config.kt
@@ -4,11 +4,11 @@ object Config {
const val migrationCode = 2
// Tachidesk-Server version
- const val tachideskVersion = "v0.6.4"
+ const val tachideskVersion = "v0.6.5"
// Match this to the Tachidesk-Server commit count
- const val serverCode = 1118
+ const val serverCode = 1143
const val preview = true
- const val previewCommit = "d989940a4dcdf8d5cbdc2fdfdfc40849117dc85c"
+ const val previewCommit = "2ac5c1362c0c5bb8f39d1049d6f72328102dd182"
val desktopJvmTarget = JavaVersion.VERSION_17
val androidJvmTarget = JavaVersion.VERSION_11
diff --git a/i18n/src/commonMain/resources/MR/values/base/strings.xml b/i18n/src/commonMain/resources/MR/values/base/strings.xml
index 53e0d832..dd7d5550 100644
--- a/i18n/src/commonMain/resources/MR/values/base/strings.xml
+++ b/i18n/src/commonMain/resources/MR/values/base/strings.xml
@@ -32,9 +32,6 @@
Install
Uninstall
Update
- Toggle read
- Mark previous as read
- Toggle bookmarked
Favorite
Unfavorite
Refresh
@@ -46,6 +43,15 @@
Ok
Browser
Filter
+ Mark as read
+ Mark as unread
+ Download
+ Bookmark chapter
+ Unbookmark chapter
+ Mark previous as read
+ Bookmarked
+ Select all
+ Select inverse
Library
@@ -107,6 +113,11 @@
Failed to load manga
Categories
Select Categories
+ Next chapter
+ Next 5 chapters
+ Next 10 chapters
+ All
+ Unread
Unknown
Ongoing
diff --git a/presentation/src/androidMain/kotlin/ca/gosyer/jui/ui/manga/components/AndroidChapterItem.kt b/presentation/src/androidMain/kotlin/ca/gosyer/jui/ui/manga/components/AndroidChapterItem.kt
index 6c8748ca..17d90f07 100644
--- a/presentation/src/androidMain/kotlin/ca/gosyer/jui/ui/manga/components/AndroidChapterItem.kt
+++ b/presentation/src/androidMain/kotlin/ca/gosyer/jui/ui/manga/components/AndroidChapterItem.kt
@@ -7,49 +7,18 @@
package ca.gosyer.jui.ui.manga.components
import androidx.compose.foundation.combinedClickable
-import androidx.compose.material.DropdownMenu
-import androidx.compose.material.DropdownMenuItem
-import androidx.compose.material.Text
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.composed
-import ca.gosyer.jui.i18n.MR
-import ca.gosyer.jui.uicore.resources.stringResource
actual fun Modifier.chapterItemModifier(
onClick: () -> Unit,
- toggleRead: () -> Unit,
- toggleBookmarked: () -> Unit,
- markPreviousAsRead: () -> Unit
-): Modifier = composed {
- var expanded by remember { mutableStateOf(false) }
- DropdownMenu(
- expanded,
- onDismissRequest = { expanded = false }
- ) {
- listOf(
- stringResource(MR.strings.action_toggle_read) to toggleRead,
- stringResource(MR.strings.action_mark_previous_read) to markPreviousAsRead,
- stringResource(MR.strings.action_toggle_bookmarked) to toggleBookmarked
- ).forEach { (label, onClick) ->
- DropdownMenuItem(
- onClick = {
- expanded = false
- onClick()
- }
- ) {
- Text(text = label)
- }
- }
- }
-
- Modifier.combinedClickable(
- onClick = { onClick() },
- onLongClick = {
- expanded = true
- }
- )
-}
+ markRead: (() -> Unit)?,
+ markUnread: (() -> Unit)?,
+ bookmarkChapter: (() -> Unit)?,
+ unBookmarkChapter: (() -> Unit)?,
+ markPreviousAsRead: () -> Unit,
+ onSelectChapter: (() -> Unit)?,
+ onUnselectChapter: (() -> Unit)?
+): Modifier = combinedClickable(
+ onClick = onUnselectChapter ?: onClick,
+ onLongClick = onSelectChapter
+)
diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/chapter/ChapterDownloadButtons.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/chapter/ChapterDownloadButtons.kt
index 5551e54f..a82e1b9e 100644
--- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/chapter/ChapterDownloadButtons.kt
+++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/chapter/ChapterDownloadButtons.kt
@@ -49,16 +49,19 @@ import kotlinx.coroutines.flow.asStateFlow
data class ChapterDownloadItem(
val manga: Manga?,
val chapter: Chapter,
- private val _downloadState: MutableStateFlow = MutableStateFlow(
- if (chapter.downloaded) {
- ChapterDownloadState.Downloaded
- } else {
- ChapterDownloadState.NotDownloaded
- }
- ),
- private val _downloadChapterFlow: MutableStateFlow = MutableStateFlow(null)
) {
+ private val _isSelected = MutableStateFlow(false)
+ val isSelected = _isSelected.asStateFlow()
+
+ private val _downloadState: MutableStateFlow = MutableStateFlow(
+ when (chapter.downloaded) {
+ true -> ChapterDownloadState.Downloaded
+ false -> ChapterDownloadState.NotDownloaded
+ }
+ )
val downloadState = _downloadState.asStateFlow()
+
+ private val _downloadChapterFlow: MutableStateFlow = MutableStateFlow(null)
val downloadChapterFlow = _downloadChapterFlow.asStateFlow()
fun updateFrom(downloadingChapters: List) {
@@ -83,6 +86,10 @@ data class ChapterDownloadItem(
stopChapterDownload.await(chapter)
_downloadState.value = ChapterDownloadState.NotDownloaded
}
+
+ fun isSelected(selectedItems: List): Boolean {
+ return (chapter.id in selectedItems).also { _isSelected.value = it }
+ }
}
enum class ChapterDownloadState {
diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/navigation/ActionMenu.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/navigation/ActionMenu.kt
index 6f6635b4..733415c6 100644
--- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/navigation/ActionMenu.kt
+++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/navigation/ActionMenu.kt
@@ -16,10 +16,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import ca.gosyer.jui.i18n.MR
import ca.gosyer.jui.uicore.components.DropdownMenu
@@ -34,14 +38,27 @@ import kotlinx.collections.immutable.ImmutableList
// As an item on the action bar, the action will be displayed with an IconButton
// with the given icon, if not null. Otherwise, the string from the name resource is used.
// In overflow menu, item will always be displayed as text.
+sealed class Action {
+ abstract val name: String
+ open val icon: ImageVector? = null
+ open val overflowMode: OverflowMode = OverflowMode.IF_NECESSARY
+ open val enabled: Boolean = true
+}
+
+data class ActionGroup(
+ override val name: String,
+ override val icon: ImageVector? = null,
+ val actions: ImmutableList
+) : Action()
+
@Stable
data class ActionItem(
- val name: String,
- val icon: ImageVector? = null,
- val overflowMode: OverflowMode = OverflowMode.IF_NECESSARY,
- val enabled: Boolean = true,
+ override val name: String,
+ override val icon: ImageVector? = null,
+ override val overflowMode: OverflowMode = OverflowMode.IF_NECESSARY,
+ override val enabled: Boolean = true,
val doAction: () -> Unit
-) {
+) : Action() {
// allow 'calling' the action like a function
operator fun invoke() = doAction()
}
@@ -54,7 +71,7 @@ enum class OverflowMode {
// Note: should be used in a RowScope
@Composable
fun ActionMenu(
- items: ImmutableList,
+ items: ImmutableList,
numIcons: Int = 3, // includes overflow menu icon; may be overridden by NEVER_OVERFLOW
menuVisible: MutableState = remember { mutableStateOf(false) },
iconItem: @Composable (onClick: () -> Unit, name: String, icon: ImageVector, enabled: Boolean) -> Unit
@@ -67,12 +84,23 @@ fun ActionMenu(
separateIntoIconAndOverflow(items, numIcons)
}.value
+ var openGroup by remember { mutableStateOf(null) }
+
appbarActions.fastForEach { item ->
key(item.hashCode()) {
if (item.icon != null) {
- iconItem(item.doAction, item.name, item.icon, item.enabled)
+ when (item) {
+ is ActionGroup -> iconItem({ openGroup = item }, item.name, item.icon!!, item.enabled)
+ is ActionItem -> iconItem(item.doAction, item.name, item.icon!!, item.enabled)
+ }
} else {
- TextButton(onClick = item.doAction, enabled = item.enabled) {
+ TextButton(
+ onClick = when (item) {
+ is ActionGroup -> { { openGroup = item } }
+ is ActionItem -> item.doAction
+ },
+ enabled = item.enabled
+ ) {
Text(
text = item.name,
color = MaterialTheme.colors.onPrimary.copy(alpha = LocalContentAlpha.current)
@@ -91,14 +119,18 @@ fun ActionMenu(
)
DropdownMenu(
expanded = menuVisible.value,
- onDismissRequest = { menuVisible.value = false }
+ onDismissRequest = { menuVisible.value = false },
+ offset = DpOffset(8.dp, (-56).dp),
) {
overflowActions.fastForEach { item ->
key(item.hashCode()) {
DropdownMenuItem(
onClick = {
menuVisible.value = false
- item()
+ when (item) {
+ is ActionGroup -> openGroup = item
+ is ActionItem -> item()
+ }
},
enabled = item.enabled
) {
@@ -109,12 +141,37 @@ fun ActionMenu(
}
}
}
+ DropdownMenu(
+ openGroup != null,
+ onDismissRequest = { openGroup = null },
+ offset = DpOffset(8.dp, (-56).dp),
+ ) {
+ openGroup?.actions?.fastForEach { item ->
+ key(item.hashCode()) {
+ DropdownMenuItem(
+ onClick = {
+ when (item) {
+ is ActionGroup -> openGroup = item
+ is ActionItem -> {
+ openGroup = null
+ item()
+ }
+ }
+ },
+ enabled = item.enabled
+ ) {
+ // Icon(item.icon, item.name) just have text in the overflow menu
+ Text(item.name)
+ }
+ }
+ }
+ }
}
private fun separateIntoIconAndOverflow(
- items: ImmutableList,
+ items: ImmutableList,
numIcons: Int
-): Pair, List> {
+): Pair, List> {
var (iconCount, overflowCount, preferIconCount) = Triple(0, 0, 0)
for (item in items) {
when (item.overflowMode) {
@@ -128,8 +185,8 @@ private fun separateIntoIconAndOverflow(
val needsOverflow = iconCount + preferIconCount > numIcons || overflowCount > 0
val actionIconSpace = numIcons - (if (needsOverflow) 1 else 0)
- val iconActions = ArrayList()
- val overflowActions = ArrayList()
+ val iconActions = ArrayList()
+ val overflowActions = ArrayList()
var iconsAvailableBeforeOverflow = actionIconSpace - iconCount
for (item in items) {
diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/navigation/Toolbar.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/navigation/Toolbar.kt
index 5fd88dd9..bf9222b8 100644
--- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/navigation/Toolbar.kt
+++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/base/navigation/Toolbar.kt
@@ -77,14 +77,17 @@ import cafe.adriel.voyager.navigator.Navigator
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
+val ToolbarDefault = ImageVector.Builder(defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f).build()
+
@Composable
fun Toolbar(
name: String,
navigator: Navigator? = LocalNavigator.current,
closable: Boolean = (navigator?.size ?: 0) > 1,
onClose: () -> Unit = { navigator?.pop() },
+ closeIcon: ImageVector = ToolbarDefault,
modifier: Modifier = Modifier,
- actions: @Composable () -> ImmutableList = { remember { persistentListOf() } },
+ actions: @Composable () -> ImmutableList = { remember { persistentListOf() } },
backgroundColor: Color = MaterialTheme.colors.surface, // CustomColors.current.bars,
contentColor: Color = contentColorFor(backgroundColor), // CustomColors.current.onBars,
elevation: Dp = Dp.Hairline,
@@ -98,6 +101,7 @@ fun Toolbar(
name = name,
closable = closable,
onClose = onClose,
+ closeIcon = closeIcon,
modifier = modifier,
actions = actions,
backgroundColor = backgroundColor,
@@ -112,6 +116,7 @@ fun Toolbar(
name = name,
closable = closable,
onClose = onClose,
+ closeIcon = closeIcon,
modifier = modifier,
actions = actions,
backgroundColor = backgroundColor,
@@ -130,8 +135,9 @@ private fun WideToolbar(
name: String,
closable: Boolean,
onClose: () -> Unit,
+ closeIcon: ImageVector,
modifier: Modifier,
- actions: @Composable () -> ImmutableList = { remember { persistentListOf() } },
+ actions: @Composable () -> ImmutableList = { remember { persistentListOf() } },
backgroundColor: Color,
contentColor: Color,
elevation: Dp,
@@ -186,7 +192,7 @@ private fun WideToolbar(
TextActionIcon(
onClick = onClose,
text = stringResource(MR.strings.action_close),
- icon = Icons.Rounded.Close
+ icon = if (closeIcon === ToolbarDefault) Icons.Rounded.Close else closeIcon,
)
}
}
@@ -199,8 +205,9 @@ private fun ThinToolbar(
name: String,
closable: Boolean,
onClose: () -> Unit,
+ closeIcon: ImageVector,
modifier: Modifier,
- actions: @Composable () -> ImmutableList = { remember { persistentListOf() } },
+ actions: @Composable () -> ImmutableList = { remember { persistentListOf() } },
backgroundColor: Color,
contentColor: Color,
elevation: Dp,
@@ -247,7 +254,7 @@ private fun ThinToolbar(
}
) {
Icon(
- Icons.Rounded.ArrowBack,
+ if (closeIcon === ToolbarDefault) Icons.Rounded.ArrowBack else closeIcon,
stringResource(MR.strings.action_close)
)
}
diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/MangaScreen.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/MangaScreen.kt
index 74081167..3b88d418 100644
--- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/MangaScreen.kt
+++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/MangaScreen.kt
@@ -33,16 +33,29 @@ class MangaScreen(private val mangaId: Long) : Screen {
chooseCategoriesFlowHolder = vm.chooseCategoriesFlowHolder,
availableCategories = vm.categories.collectAsState().value,
mangaCategories = vm.mangaCategories.collectAsState().value,
+ inActionMode = vm.inActionMode.collectAsState().value,
+ selectedItems = vm.selectedItems.collectAsState().value,
addFavorite = vm::addFavorite,
setCategories = vm::setCategories,
toggleFavorite = vm::toggleFavorite,
refreshManga = vm::refreshManga,
- toggleRead = vm::toggleRead,
- toggleBookmarked = vm::toggleBookmarked,
+ downloadNext = vm::downloadNext,
+ downloadUnread = vm::downloadUnread,
+ downloadAll = vm::downloadAll,
+ markRead = vm::markRead,
+ markUnread = vm::markUnread,
+ bookmarkChapter = vm::bookmarkChapter,
+ unBookmarkChapter = vm::unBookmarkChapter,
markPreviousRead = vm::markPreviousRead,
downloadChapter = vm::downloadChapter,
deleteDownload = vm::deleteDownload,
stopDownloadingChapter = vm::stopDownloadingChapter,
+ onSelectChapter = vm::selectChapter,
+ onUnselectChapter = vm::unselectChapter,
+ selectAll = vm::selectAll,
+ invertSelection = vm::invertSelection,
+ clearSelection = vm::clearSelection,
+ downloadChapters = vm::downloadChapters,
loadChapters = vm::loadChapters,
loadManga = vm::loadManga
)
diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/MangaScreenViewModel.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/MangaScreenViewModel.kt
index 5f7c75b9..32cc5a97 100644
--- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/MangaScreenViewModel.kt
+++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/MangaScreenViewModel.kt
@@ -20,6 +20,7 @@ import ca.gosyer.jui.domain.chapter.interactor.UpdateChapterBookmarked
import ca.gosyer.jui.domain.chapter.interactor.UpdateChapterMarkPreviousRead
import ca.gosyer.jui.domain.chapter.interactor.UpdateChapterRead
import ca.gosyer.jui.domain.chapter.model.Chapter
+import ca.gosyer.jui.domain.download.interactor.BatchChapterDownload
import ca.gosyer.jui.domain.download.interactor.QueueChapterDownload
import ca.gosyer.jui.domain.download.interactor.StopChapterDownload
import ca.gosyer.jui.domain.download.service.DownloadService
@@ -30,6 +31,7 @@ import ca.gosyer.jui.domain.manga.interactor.RefreshManga
import ca.gosyer.jui.domain.manga.model.Manga
import ca.gosyer.jui.domain.ui.service.UiPreferences
import ca.gosyer.jui.ui.base.chapter.ChapterDownloadItem
+import ca.gosyer.jui.ui.base.chapter.ChapterDownloadState
import ca.gosyer.jui.ui.base.model.StableHolder
import ca.gosyer.jui.uicore.vm.ContextWrapper
import ca.gosyer.jui.uicore.vm.ViewModel
@@ -43,6 +45,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
@@ -69,6 +72,7 @@ class MangaScreenViewModel @Inject constructor(
private val removeMangaFromCategory: RemoveMangaFromCategory,
private val addMangaToLibrary: AddMangaToLibrary,
private val removeMangaFromLibrary: RemoveMangaFromLibrary,
+ private val batchChapterDownload: BatchChapterDownload,
uiPreferences: UiPreferences,
contextWrapper: ContextWrapper,
private val params: Params
@@ -79,6 +83,11 @@ class MangaScreenViewModel @Inject constructor(
private val _chapters = MutableStateFlow>(persistentListOf())
val chapters = _chapters.asStateFlow()
+ private val _selectedIds = MutableStateFlow>(persistentListOf())
+ val selectedItems = combine(chapters, _selectedIds) { chapters, selecteditems ->
+ chapters.filter { it.isSelected(selecteditems) }.toImmutableList()
+ }.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
+
private val _isLoading = MutableStateFlow(true)
val isLoading = _isLoading.asStateFlow()
@@ -96,6 +105,9 @@ class MangaScreenViewModel @Inject constructor(
val categoriesExist = categories.map { it.isNotEmpty() }
.stateIn(scope, SharingStarted.Eagerly, true)
+ val inActionMode = _selectedIds.map { it.isNotEmpty() }
+ .stateIn(scope, SharingStarted.Eagerly, false)
+
private val chooseCategoriesFlow = MutableSharedFlow()
val chooseCategoriesFlowHolder = StableHolder(chooseCategoriesFlow.asSharedFlow())
@@ -118,6 +130,8 @@ class MangaScreenViewModel @Inject constructor(
refreshMangaAsync(params.mangaId).await() to refreshChaptersAsync(params.mangaId).await()
_isLoading.value = false
}
+
+
}
fun loadManga() {
@@ -219,31 +233,40 @@ class MangaScreenViewModel @Inject constructor(
private fun findChapter(index: Int) = chapters.value.find { it.chapter.index == index }?.chapter
- fun toggleRead(index: Int) {
+ private fun setRead(index: Int, read: Boolean) {
val chapter = findChapter(index) ?: return
+ if (chapter.read == read) return
scope.launch {
manga.value?.let { manga ->
- updateChapterRead.await(manga, index, read = chapter.read.not(), onError = { toast(it.message.orEmpty()) })
+ updateChapterRead.await(manga, index, read = read, onError = { toast(it.message.orEmpty()) })
refreshChaptersAsync(manga.id).await()
+ _selectedIds.value = _selectedIds.value.minus(chapter.id).toImmutableList()
}
}
}
+ fun markRead(index: Int) = setRead(index, true)
+ fun markUnread(index: Int) = setRead(index, false)
- fun toggleBookmarked(index: Int) {
+ private fun setBookmarked(index: Int, bookmark: Boolean) {
val chapter = findChapter(index) ?: return
+ if (chapter.bookmarked == bookmark) return
scope.launch {
manga.value?.let { manga ->
- updateChapterBookmarked.await(manga, index, bookmarked = chapter.bookmarked.not(), onError = { toast(it.message.orEmpty()) })
+ updateChapterBookmarked.await(manga, index, bookmarked = bookmark, onError = { toast(it.message.orEmpty()) })
refreshChaptersAsync(manga.id).await()
+ _selectedIds.value = _selectedIds.value.minus(chapter.id).toImmutableList()
}
}
}
+ fun bookmarkChapter(index: Int) = setBookmarked(index, true)
+ fun unBookmarkChapter(index: Int) = setBookmarked(index, false)
fun markPreviousRead(index: Int) {
scope.launch {
manga.value?.let { manga ->
updateChapterMarkPreviousRead.await(manga, index, onError = { toast(it.message.orEmpty()) })
refreshChaptersAsync(manga.id).await()
+ _selectedIds.value = persistentListOf()
}
}
}
@@ -268,6 +291,73 @@ class MangaScreenViewModel @Inject constructor(
}
}
+ fun selectAll() {
+ scope.launch {
+ _selectedIds.value = chapters.value.map { it.chapter.id }.toImmutableList()
+ }
+ }
+
+ fun invertSelection() {
+ scope.launch {
+ _selectedIds.value = chapters.value.map { it.chapter.id }.minus(_selectedIds.value).toImmutableList()
+ }
+ }
+
+ fun selectChapter(index: Int) {
+ scope.launch {
+ chapters.value.find { it.chapter.index == index }
+ ?.let { _selectedIds.value = _selectedIds.value.plus(it.chapter.id).toImmutableList() }
+ }
+ }
+ fun unselectChapter(index: Int) {
+ scope.launch {
+ chapters.value.find { it.chapter.index == index }
+ ?.let { _selectedIds.value = _selectedIds.value.minus(it.chapter.id).toImmutableList() }
+ }
+ }
+
+ fun clearSelection() {
+ scope.launch {
+ _selectedIds.value = persistentListOf()
+ }
+ }
+
+ fun downloadChapters() {
+ scope.launch {
+ batchChapterDownload.await(_selectedIds.value)
+ _selectedIds.value = persistentListOf()
+ }
+ }
+
+ fun downloadNext(next: Int) {
+ scope.launch {
+ batchChapterDownload.await(
+ _chapters.value.filter { !it.chapter.read && it.downloadState.value == ChapterDownloadState.NotDownloaded }
+ .map { it.chapter.id }
+ .takeLast(next)
+ )
+ }
+ }
+
+ fun downloadUnread() {
+ scope.launch {
+ batchChapterDownload.await(
+ _chapters.value.filter { !it.chapter.read && it.downloadState.value == ChapterDownloadState.NotDownloaded }
+ .map { it.chapter.id }
+ )
+ }
+ }
+
+ fun downloadAll() {
+ scope.launch {
+ batchChapterDownload.await(
+ _chapters.value
+ .filter { it.downloadState.value == ChapterDownloadState.NotDownloaded }
+ .map { it.chapter.id }
+ )
+ }
+ }
+
private fun List.toDownloadChapters() = map {
ChapterDownloadItem(null, it)
}.toImmutableList()
diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/components/ChapterItem.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/components/ChapterItem.kt
index 7e6eafc1..66b4db8c 100644
--- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/components/ChapterItem.kt
+++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/components/ChapterItem.kt
@@ -10,19 +10,27 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.text.selection.SelectionContainer
-import androidx.compose.material.Card
import androidx.compose.material.ContentAlpha
-import androidx.compose.material.LocalContentColor
+import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@@ -31,14 +39,19 @@ import androidx.compose.ui.unit.dp
import ca.gosyer.jui.i18n.MR
import ca.gosyer.jui.ui.base.chapter.ChapterDownloadIcon
import ca.gosyer.jui.ui.base.chapter.ChapterDownloadItem
+import ca.gosyer.jui.uicore.components.selectedBackground
import ca.gosyer.jui.uicore.resources.stringResource
import kotlinx.datetime.Instant
expect fun Modifier.chapterItemModifier(
onClick: () -> Unit,
- toggleRead: () -> Unit,
- toggleBookmarked: () -> Unit,
- markPreviousAsRead: () -> Unit
+ markRead: (() -> Unit)?,
+ markUnread: (() -> Unit)?,
+ bookmarkChapter: (() -> Unit)?,
+ unBookmarkChapter: (() -> Unit)?,
+ markPreviousAsRead: () -> Unit,
+ onSelectChapter: (() -> Unit)?,
+ onUnselectChapter: (() -> Unit)?
): Modifier
@Composable
@@ -46,80 +59,106 @@ fun ChapterItem(
chapterDownload: ChapterDownloadItem,
format: (Instant) -> String,
onClick: (Int) -> Unit,
- toggleRead: (Int) -> Unit,
- toggleBookmarked: (Int) -> Unit,
+ markRead: (Int) -> Unit,
+ markUnread: (Int) -> Unit,
+ bookmarkChapter: (Int) -> Unit,
+ unBookmarkChapter: (Int) -> Unit,
markPreviousAsRead: (Int) -> Unit,
onClickDownload: (Int) -> Unit,
onClickStopDownload: (Int) -> Unit,
- onClickDeleteChapter: (Int) -> Unit
+ onClickDeleteChapter: (Int) -> Unit,
+ onSelectChapter: (Int) -> Unit,
+ onUnselectChapter: (Int) -> Unit
) {
val chapter = chapterDownload.chapter
- Card(
- modifier = Modifier.fillMaxWidth().height(70.dp).padding(4.dp),
- elevation = 1.dp,
- shape = RoundedCornerShape(4.dp)
- ) {
- BoxWithConstraints(
- Modifier.chapterItemModifier(
+ val isSelected by chapterDownload.isSelected.collectAsState()
+ BoxWithConstraints(
+ Modifier
+ .fillMaxWidth()
+ .height(70.dp)
+ .selectedBackground(isSelected)
+ .chapterItemModifier(
onClick = { onClick(chapter.index) },
- toggleRead = { toggleRead(chapter.index) },
- toggleBookmarked = { toggleBookmarked(chapter.index) },
- markPreviousAsRead = { markPreviousAsRead(chapter.index) }
+ markRead = { markRead(chapter.index) }.takeUnless { chapter.read },
+ markUnread = { markUnread(chapter.index) }.takeIf { chapter.read },
+ bookmarkChapter = { bookmarkChapter(chapter.index) }.takeUnless { chapter.bookmarked },
+ unBookmarkChapter = { unBookmarkChapter(chapter.index) }.takeIf { chapter.bookmarked },
+ markPreviousAsRead = { markPreviousAsRead(chapter.index) },
+ onSelectChapter = { onSelectChapter(chapter.index) }.takeUnless { chapterDownload.isSelected.value },
+ onUnselectChapter = { onUnselectChapter(chapter.index) }.takeIf { chapterDownload.isSelected.value }
)
+ .padding(4.dp)
+ ) {
+ val textColor = if (chapter.bookmarked && !chapter.read) {
+ MaterialTheme.colors.primary
+ } else {
+ MaterialTheme.colors.onSurface
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
) {
- Row(
- horizontalArrangement = Arrangement.SpaceBetween
+ Column(
+ Modifier.padding(4.dp).width(this@BoxWithConstraints.maxWidth - 60.dp)
) {
- Column(
- Modifier.padding(4.dp).width(this@BoxWithConstraints.maxWidth - 60.dp)
- ) {
- SelectionContainer {
- Text(
- chapter.name,
- maxLines = 1,
- style = MaterialTheme.typography.h6,
- color = LocalContentColor.current.copy(
- alpha = if (chapter.read) ContentAlpha.disabled else ContentAlpha.high
- ),
- overflow = TextOverflow.Ellipsis
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ var textHeight by remember { mutableStateOf(0) }
+ if (chapter.bookmarked) {
+ Icon(
+ imageVector = Icons.Filled.Bookmark,
+ contentDescription = stringResource(MR.strings.action_filter_bookmarked),
+ modifier = Modifier
+ .sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }),
+ tint = MaterialTheme.colors.primary,
)
+ Spacer(modifier = Modifier.width(2.dp))
}
- val subtitleStr = buildAnnotatedString {
- if (chapter.uploadDate > 0) {
- append(format(Instant.fromEpochMilliseconds(chapter.uploadDate)))
- }
- if (!chapter.read && chapter.lastPageRead > 0) {
- if (length > 0) append(" • ")
- append(
- AnnotatedString(
- stringResource(MR.strings.page_progress, (chapter.lastPageRead + 1)),
- SpanStyle(color = LocalContentColor.current.copy(alpha = ContentAlpha.disabled))
- )
- )
- }
- if (!chapter.scanlator.isNullOrBlank()) {
- if (length > 0) append(" • ")
- append(chapter.scanlator!!)
+ Text(
+ chapter.name,
+ maxLines = 1,
+ style = MaterialTheme.typography.h6,
+ color = textColor.copy(
+ alpha = if (chapter.read) ContentAlpha.disabled else ContentAlpha.high
+ ),
+ overflow = TextOverflow.Ellipsis,
+ onTextLayout = {
+ textHeight = it.size.height
}
+ )
+ }
+ val subtitleStr = buildAnnotatedString {
+ if (chapter.uploadDate > 0) {
+ append(format(Instant.fromEpochMilliseconds(chapter.uploadDate)))
}
- SelectionContainer {
- Text(
- subtitleStr,
- style = MaterialTheme.typography.body2,
- color = LocalContentColor.current.copy(
- alpha = if (chapter.read) ContentAlpha.disabled else ContentAlpha.medium
+ if (!chapter.read && chapter.lastPageRead > 0) {
+ if (length > 0) append(" • ")
+ append(
+ AnnotatedString(
+ stringResource(MR.strings.page_progress, (chapter.lastPageRead + 1)),
+ SpanStyle(color = textColor.copy(alpha = ContentAlpha.disabled))
)
)
}
+ if (!chapter.scanlator.isNullOrBlank()) {
+ if (length > 0) append(" • ")
+ append(chapter.scanlator!!)
+ }
}
-
- ChapterDownloadIcon(
- chapterDownload,
- { onClickDownload(it.index) },
- { onClickStopDownload(it.index) },
- { onClickDeleteChapter(it.index) }
+ Text(
+ subtitleStr,
+ style = MaterialTheme.typography.body2,
+ color = textColor.copy(
+ alpha = if (chapter.read) ContentAlpha.disabled else ContentAlpha.medium
+ )
)
}
+
+ ChapterDownloadIcon(
+ chapterDownload,
+ { onClickDownload(it.index) },
+ { onClickStopDownload(it.index) },
+ { onClickDeleteChapter(it.index) }
+ )
}
}
}
diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/components/MangaScreenContent.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/components/MangaScreenContent.kt
index d3b59650..5ce9dfd8 100644
--- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/components/MangaScreenContent.kt
+++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/manga/components/MangaScreenContent.kt
@@ -23,11 +23,20 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Scaffold
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.BookmarkAdd
+import androidx.compose.material.icons.rounded.BookmarkRemove
+import androidx.compose.material.icons.rounded.Close
+import androidx.compose.material.icons.rounded.Delete
+import androidx.compose.material.icons.rounded.DoneAll
+import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material.icons.rounded.FavoriteBorder
+import androidx.compose.material.icons.rounded.FlipToBack
import androidx.compose.material.icons.rounded.Label
import androidx.compose.material.icons.rounded.OpenInBrowser
import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material.icons.rounded.RemoveDone
+import androidx.compose.material.icons.rounded.SelectAll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
@@ -35,23 +44,34 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastAny
import ca.gosyer.jui.domain.category.model.Category
import ca.gosyer.jui.domain.manga.model.Manga
import ca.gosyer.jui.i18n.MR
import ca.gosyer.jui.ui.base.chapter.ChapterDownloadItem
+import ca.gosyer.jui.ui.base.chapter.ChapterDownloadState
import ca.gosyer.jui.ui.base.model.StableHolder
+import ca.gosyer.jui.ui.base.navigation.Action
+import ca.gosyer.jui.ui.base.navigation.ActionGroup
import ca.gosyer.jui.ui.base.navigation.ActionItem
+import ca.gosyer.jui.ui.base.navigation.BackHandler
import ca.gosyer.jui.ui.base.navigation.Toolbar
+import ca.gosyer.jui.ui.base.navigation.ToolbarDefault
import ca.gosyer.jui.ui.main.components.bottomNav
import ca.gosyer.jui.ui.reader.rememberReaderLauncher
+import ca.gosyer.jui.uicore.components.BottomActionItem
+import ca.gosyer.jui.uicore.components.BottomActionMenu
import ca.gosyer.jui.uicore.components.ErrorScreen
import ca.gosyer.jui.uicore.components.LoadingScreen
import ca.gosyer.jui.uicore.components.VerticalScrollbar
import ca.gosyer.jui.uicore.components.rememberScrollbarAdapter
import ca.gosyer.jui.uicore.components.scrollbarPadding
+import ca.gosyer.jui.uicore.icons.JuiAssets
+import ca.gosyer.jui.uicore.icons.juiassets.DonePrev
import ca.gosyer.jui.uicore.insets.navigationBars
import ca.gosyer.jui.uicore.insets.statusBars
import ca.gosyer.jui.uicore.resources.stringResource
+import cafe.adriel.voyager.navigator.LocalNavigator
import com.vanpra.composematerialdialogs.rememberMaterialDialogState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -68,16 +88,29 @@ fun MangaScreenContent(
chooseCategoriesFlowHolder: StableHolder>,
availableCategories: ImmutableList,
mangaCategories: ImmutableList,
+ inActionMode: Boolean,
+ selectedItems: ImmutableList,
addFavorite: (List, List) -> Unit,
setCategories: () -> Unit,
toggleFavorite: () -> Unit,
refreshManga: () -> Unit,
- toggleRead: (Int) -> Unit,
- toggleBookmarked: (Int) -> Unit,
+ downloadNext: (Int) -> Unit,
+ downloadUnread: () -> Unit,
+ downloadAll: () -> Unit,
+ markRead: (Int) -> Unit,
+ markUnread: (Int) -> Unit,
+ bookmarkChapter: (Int) -> Unit,
+ unBookmarkChapter: (Int) -> Unit,
markPreviousRead: (Int) -> Unit,
downloadChapter: (Int) -> Unit,
deleteDownload: (Int) -> Unit,
stopDownloadingChapter: (Int) -> Unit,
+ onSelectChapter: (Int) -> Unit,
+ onUnselectChapter: (Int) -> Unit,
+ selectAll: () -> Unit,
+ invertSelection: () -> Unit,
+ clearSelection: () -> Unit,
+ downloadChapters: () -> Unit,
loadChapters: () -> Unit,
loadManga: () -> Unit
) {
@@ -90,6 +123,10 @@ fun MangaScreenContent(
val readerLauncher = rememberReaderLauncher()
readerLauncher.Reader()
+ BackHandler(inActionMode) {
+ clearSelection()
+ }
+
Scaffold(
modifier = Modifier.windowInsetsPadding(
WindowInsets.statusBars.add(
@@ -97,24 +134,58 @@ fun MangaScreenContent(
)
),
topBar = {
+ val navigator = LocalNavigator.current
Toolbar(
- stringResource(MR.strings.location_manga),
+ if (inActionMode) selectedItems.size.toString() else stringResource(MR.strings.location_manga),
actions = {
- val uriHandler = LocalUriHandler.current
- getActionItems(
- refreshManga = refreshManga,
- refreshMangaEnabled = !isLoading,
- categoryItemVisible = categoriesExist && manga?.inLibrary == true,
- setCategories = setCategories,
- inLibrary = manga?.inLibrary == true,
- toggleFavorite = toggleFavorite,
- favoritesButtonEnabled = manga != null,
- openInBrowserEnabled = manga?.realUrl != null,
- openInBrowser = {
- manga?.realUrl?.let { uriHandler.openUri(it) }
- }
- )
- }
+ if (inActionMode) {
+ getActionModeActionItems(
+ selectAll = selectAll,
+ invertSelection = invertSelection
+ )
+ } else {
+ val uriHandler = LocalUriHandler.current
+ getActionItems(
+ refreshManga = refreshManga,
+ refreshMangaEnabled = !isLoading,
+ categoryItemVisible = categoriesExist && manga?.inLibrary == true,
+ setCategories = setCategories,
+ inLibrary = manga?.inLibrary == true,
+ toggleFavorite = toggleFavorite,
+ favoritesButtonEnabled = manga != null,
+ openInBrowserEnabled = manga?.realUrl != null,
+ openInBrowser = {
+ manga?.realUrl?.let { uriHandler.openUri(it) }
+ },
+ downloadNext = downloadNext,
+ downloadUnread = downloadUnread,
+ downloadAll = downloadAll
+ )
+ }
+ },
+ onClose = {
+ if (inActionMode) {
+ clearSelection()
+ } else {
+ navigator?.pop()
+ }
+ },
+ closeIcon = if (inActionMode) Icons.Rounded.Close else ToolbarDefault
+ )
+ },
+ bottomBar = {
+ BottomActionMenu(
+ visible = inActionMode,
+ items = getBottomActionItems(
+ selectedItems = selectedItems,
+ markRead = markRead,
+ markUnread = markUnread,
+ bookmarkChapter = bookmarkChapter,
+ unBookmarkChapter = unBookmarkChapter,
+ markPreviousAsRead = markPreviousRead,
+ deleteChapter = deleteDownload,
+ downloadChapters = downloadChapters
+ )
)
}
) {
@@ -141,13 +212,21 @@ fun MangaScreenContent(
ChapterItem(
chapter,
dateTimeFormatter,
- onClick = { readerLauncher.launch(it, manga.id) },
- toggleRead = toggleRead,
- toggleBookmarked = toggleBookmarked,
+ onClick = if (inActionMode) {
+ { if (chapter.isSelected.value) onUnselectChapter(chapter.chapter.index) else onSelectChapter(chapter.chapter.index) }
+ } else {
+ { readerLauncher.launch(it, manga.id) }
+ },
+ markRead = markRead,
+ markUnread = markUnread,
+ bookmarkChapter = bookmarkChapter,
+ unBookmarkChapter = unBookmarkChapter,
markPreviousAsRead = markPreviousRead,
onClickDownload = downloadChapter,
onClickDeleteChapter = deleteDownload,
- onClickStopDownload = stopDownloadingChapter
+ onClickStopDownload = stopDownloadingChapter,
+ onSelectChapter = onSelectChapter,
+ onUnselectChapter = onUnselectChapter
)
}
} else if (!isLoading) {
@@ -197,8 +276,11 @@ private fun getActionItems(
toggleFavorite: () -> Unit,
favoritesButtonEnabled: Boolean,
openInBrowserEnabled: Boolean,
- openInBrowser: () -> Unit
-): ImmutableList {
+ openInBrowser: () -> Unit,
+ downloadNext: (Int) -> Unit,
+ downloadUnread: () -> Unit,
+ downloadAll: () -> Unit
+): ImmutableList {
return listOfNotNull(
ActionItem(
name = stringResource(MR.strings.action_refresh_manga),
@@ -225,6 +307,32 @@ private fun getActionItems(
doAction = toggleFavorite,
enabled = favoritesButtonEnabled
),
+ ActionGroup(
+ name = stringResource(MR.strings.action_download),
+ icon = Icons.Rounded.Download,
+ actions = listOf(
+ ActionItem(
+ name = stringResource(MR.strings.download_1),
+ doAction = { downloadNext(1) }
+ ),
+ ActionItem(
+ name = stringResource(MR.strings.download_5),
+ doAction = { downloadNext(5) }
+ ),
+ ActionItem(
+ name = stringResource(MR.strings.download_10),
+ doAction = { downloadNext(10) }
+ ),
+ ActionItem(
+ name = stringResource(MR.strings.download_unread),
+ doAction = downloadUnread
+ ),
+ ActionItem(
+ name = stringResource(MR.strings.download_all),
+ doAction = downloadAll
+ )
+ ).toImmutableList()
+ ),
ActionItem(
name = stringResource(MR.strings.action_browser),
icon = Icons.Rounded.OpenInBrowser,
@@ -233,3 +341,74 @@ private fun getActionItems(
)
).toImmutableList()
}
+
+@Composable
+@Stable
+private fun getActionModeActionItems(
+ selectAll: () -> Unit,
+ invertSelection: () -> Unit
+): ImmutableList {
+ return listOf(
+ ActionItem(
+ name = stringResource(MR.strings.action_select_all),
+ icon = Icons.Rounded.SelectAll,
+ doAction = selectAll
+ ),
+ ActionItem(
+ name = stringResource(MR.strings.action_select_inverse),
+ icon = Icons.Rounded.FlipToBack,
+ doAction = invertSelection
+ )
+ ).toImmutableList()
+}
+
+@Composable
+@Stable
+private fun getBottomActionItems(
+ selectedItems: ImmutableList,
+ markRead: (Int) -> Unit,
+ markUnread: (Int) -> Unit,
+ bookmarkChapter: (Int) -> Unit,
+ unBookmarkChapter: (Int) -> Unit,
+ markPreviousAsRead: (Int) -> Unit,
+ deleteChapter: (Int) -> Unit,
+ downloadChapters: () -> Unit
+): ImmutableList {
+ return listOfNotNull(
+ BottomActionItem(
+ name = stringResource(MR.strings.action_bookmark),
+ icon = Icons.Rounded.BookmarkAdd,
+ onClick = { bookmarkChapter(selectedItems.first().chapter.index) },
+ ).takeIf { selectedItems.fastAny { !it.chapter.bookmarked } && selectedItems.size == 1 },
+ BottomActionItem(
+ name = stringResource(MR.strings.action_remove_bookmark),
+ icon = Icons.Rounded.BookmarkRemove,
+ onClick = { unBookmarkChapter(selectedItems.first().chapter.index) },
+ ).takeIf { selectedItems.fastAny { it.chapter.bookmarked } && selectedItems.size == 1 },
+ BottomActionItem(
+ name = stringResource(MR.strings.action_mark_as_read),
+ icon = Icons.Rounded.DoneAll,
+ onClick = { markRead(selectedItems.first().chapter.index) },
+ ).takeIf { selectedItems.fastAny { !it.chapter.read } && selectedItems.size == 1 },
+ BottomActionItem(
+ name = stringResource(MR.strings.action_mark_as_unread),
+ icon = Icons.Rounded.RemoveDone,
+ onClick = { markUnread(selectedItems.first().chapter.index) },
+ ).takeIf { selectedItems.fastAny { !it.chapter.read } && selectedItems.size == 1 },
+ BottomActionItem(
+ name = stringResource(MR.strings.action_mark_previous_read),
+ icon = JuiAssets.DonePrev,
+ onClick = { markPreviousAsRead(selectedItems.first().chapter.index) },
+ ).takeIf { selectedItems.size == 1 },
+ BottomActionItem(
+ name = stringResource(MR.strings.action_download),
+ icon = Icons.Rounded.Download,
+ onClick = downloadChapters
+ ).takeIf { selectedItems.fastAny { it.downloadState.value != ChapterDownloadState.Downloaded } },
+ BottomActionItem(
+ name = stringResource(MR.strings.action_delete),
+ icon = Icons.Rounded.Delete,
+ onClick = { deleteChapter(selectedItems.first().chapter.index) }
+ ).takeIf { selectedItems.fastAny { it.downloadState.value == ChapterDownloadState.Downloaded } && selectedItems.size == 1 }
+ ).toImmutableList()
+}
diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/manga/components/DesktopChapterItem.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/manga/components/DesktopChapterItem.kt
index c0f33b02..74457474 100644
--- a/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/manga/components/DesktopChapterItem.kt
+++ b/presentation/src/desktopMain/kotlin/ca/gosyer/jui/ui/manga/components/DesktopChapterItem.kt
@@ -11,25 +11,37 @@ import androidx.compose.foundation.onClick
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.isCtrlPressed
import ca.gosyer.jui.i18n.MR
import ca.gosyer.jui.uicore.components.onRightClickContextMenu
import ca.gosyer.jui.uicore.resources.stringResource
actual fun Modifier.chapterItemModifier(
onClick: () -> Unit,
- toggleRead: () -> Unit,
- toggleBookmarked: () -> Unit,
- markPreviousAsRead: () -> Unit
+ markRead: (() -> Unit)?,
+ markUnread: (() -> Unit)?,
+ bookmarkChapter: (() -> Unit)?,
+ unBookmarkChapter: (() -> Unit)?,
+ markPreviousAsRead: () -> Unit,
+ onSelectChapter: (() -> Unit)?,
+ onUnselectChapter: (() -> Unit)?
): Modifier = this
.onClick(
- onClick = onClick
+ onClick = onClick,
+ onLongClick = onSelectChapter
+ )
+ .onClick(
+ onClick = onSelectChapter ?: onUnselectChapter ?: {},
+ keyboardModifiers = { isCtrlPressed }
)
.onRightClickContextMenu(
items = {
getContextItems(
- toggleRead,
- toggleBookmarked,
- markPreviousAsRead
+ markRead = markRead,
+ markUnread = markUnread,
+ bookmarkChapter = bookmarkChapter,
+ unBookmarkChapter = unBookmarkChapter,
+ markPreviousAsRead = markPreviousAsRead
)
}
)
@@ -37,13 +49,17 @@ actual fun Modifier.chapterItemModifier(
@Composable
@Stable
private fun getContextItems(
- toggleRead: () -> Unit,
- toggleBookmarked: () -> Unit,
+ markRead: (() -> Unit)?,
+ markUnread: (() -> Unit)?,
+ bookmarkChapter: (() -> Unit)?,
+ unBookmarkChapter: (() -> Unit)?,
markPreviousAsRead: () -> Unit
): List {
- return listOf(
- ContextMenuItem(stringResource(MR.strings.action_toggle_read), toggleRead),
+ return listOfNotNull(
+ if (bookmarkChapter != null) ContextMenuItem(stringResource(MR.strings.action_bookmark), bookmarkChapter) else null,
+ if (unBookmarkChapter != null) ContextMenuItem(stringResource(MR.strings.action_remove_bookmark), unBookmarkChapter) else null,
+ if (markRead != null) ContextMenuItem(stringResource(MR.strings.action_mark_as_read), markRead) else null,
+ if (markUnread != null) ContextMenuItem(stringResource(MR.strings.action_mark_as_unread), markUnread) else null,
ContextMenuItem(stringResource(MR.strings.action_mark_previous_read), markPreviousAsRead),
- ContextMenuItem(stringResource(MR.strings.action_toggle_bookmarked), toggleBookmarked)
)
}
diff --git a/ui-core/src/androidMain/kotlin/ca/gosyer/jui/uicore/components/AndroidBottomActionMenu.kt b/ui-core/src/androidMain/kotlin/ca/gosyer/jui/uicore/components/AndroidBottomActionMenu.kt
new file mode 100644
index 00000000..6fb4f48b
--- /dev/null
+++ b/ui-core/src/androidMain/kotlin/ca/gosyer/jui/uicore/components/AndroidBottomActionMenu.kt
@@ -0,0 +1,26 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package ca.gosyer.jui.uicore.components
+
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.material.ripple.rememberRipple
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+
+actual fun Modifier.buttonModifier(
+ onClick: () -> Unit,
+ onHintClick: () -> Unit,
+): Modifier = composed {
+ combinedClickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = rememberRipple(bounded = false),
+ onLongClick = onHintClick,
+ onClick = onClick,
+ )
+}
\ No newline at end of file
diff --git a/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/components/BottomActionMenu.kt b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/components/BottomActionMenu.kt
new file mode 100644
index 00000000..17de10e2
--- /dev/null
+++ b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/components/BottomActionMenu.kt
@@ -0,0 +1,151 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package ca.gosyer.jui.uicore.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.ZeroCornerSize
+import androidx.compose.material.Icon
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import ca.gosyer.jui.uicore.insets.navigationBars
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlin.time.Duration.Companion.seconds
+
+@Stable
+data class BottomActionItem(
+ val name: String,
+ val icon: ImageVector,
+ val onClick: () -> Unit
+)
+
+@Composable
+fun BottomActionMenu(
+ visible: Boolean,
+ modifier: Modifier = Modifier,
+ items: ImmutableList,
+) {
+ AnimatedVisibility(
+ visible = visible,
+ enter = expandVertically(expandFrom = Alignment.Bottom),
+ exit = shrinkVertically(shrinkTowards = Alignment.Bottom),
+ ) {
+ val scope = rememberCoroutineScope()
+ Surface(
+ modifier = modifier,
+ shape = MaterialTheme.shapes.large.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize),
+ elevation = 3.dp,
+ ) {
+ val haptic = LocalHapticFeedback.current
+ var confirm by remember { mutableStateOf(null) }
+ var resetJob: Job? = remember { null }
+ val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ confirm = toConfirmIndex
+ resetJob?.cancel()
+ resetJob = scope.launch {
+ delay(1.seconds)
+ if (isActive) confirm = null
+ }
+ }
+ Row(
+ modifier = Modifier
+ .padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues())
+ .padding(horizontal = 8.dp, vertical = 12.dp),
+ ) {
+ items.forEachIndexed { index, item ->
+ Button(
+ title = item.name,
+ icon = item.icon,
+ toConfirm = confirm == index,
+ onLongClick = { onLongClickItem(index) },
+ onClick = item.onClick,
+ )
+ }
+ }
+ }
+ }
+}
+
+expect fun Modifier.buttonModifier(
+ onClick: () -> Unit,
+ onHintClick: () -> Unit,
+): Modifier
+
+@Composable
+private fun RowScope.Button(
+ title: String,
+ icon: ImageVector,
+ toConfirm: Boolean,
+ onLongClick: () -> Unit,
+ onClick: () -> Unit,
+ content: (@Composable () -> Unit)? = null,
+) {
+ val animatedWeight by animateFloatAsState(if (toConfirm) 2f else 1f)
+ Column(
+ modifier = Modifier
+ .size(48.dp)
+ .weight(animatedWeight)
+ .buttonModifier(
+ onHintClick = onLongClick,
+ onClick = onClick,
+ ),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = title,
+ )
+ AnimatedVisibility(
+ visible = toConfirm,
+ enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
+ exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
+ ) {
+ Text(
+ text = title,
+ overflow = TextOverflow.Visible,
+ maxLines = 1,
+ style = MaterialTheme.typography.overline,
+ )
+ }
+ content?.invoke()
+ }
+}
\ No newline at end of file
diff --git a/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/components/SelectedBackground.kt b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/components/SelectedBackground.kt
new file mode 100644
index 00000000..744e3af0
--- /dev/null
+++ b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/components/SelectedBackground.kt
@@ -0,0 +1,22 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package ca.gosyer.jui.uicore.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material.MaterialTheme
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+
+fun Modifier.selectedBackground(isSelected: Boolean): Modifier = composed {
+ if (isSelected) {
+ val alpha = if (isSystemInDarkTheme()) 0.08f else 0.22f
+ background(MaterialTheme.colors.secondary.copy(alpha = alpha))
+ } else {
+ this
+ }
+}
\ No newline at end of file
diff --git a/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/__JuiAssets.kt b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/__JuiAssets.kt
new file mode 100644
index 00000000..862c04c6
--- /dev/null
+++ b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/__JuiAssets.kt
@@ -0,0 +1,20 @@
+package ca.gosyer.jui.uicore.icons
+
+import androidx.compose.ui.graphics.vector.ImageVector
+import ca.gosyer.jui.uicore.icons.juiassets.AllAssets
+import ca.gosyer.jui.uicore.icons.juiassets.Ca
+import ca.gosyer.jui.uicore.icons.juiassets.DonePrev
+import kotlin.collections.List as ____KtList
+
+public object JuiAssets
+
+private var __AllAssets: ____KtList? = null
+
+public val JuiAssets.AllAssets: ____KtList
+ get() {
+ if (__AllAssets != null) {
+ return __AllAssets!!
+ }
+ __AllAssets= Ca.AllAssets + listOf(DonePrev)
+ return __AllAssets!!
+ }
diff --git a/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/DonePrev.kt b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/DonePrev.kt
new file mode 100644
index 00000000..509e1ec5
--- /dev/null
+++ b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/DonePrev.kt
@@ -0,0 +1,53 @@
+package ca.gosyer.jui.uicore.icons.juiassets
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathFillType
+import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.ImageVector.Builder
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.unit.dp
+import ca.gosyer.jui.uicore.icons.JuiAssets
+
+public val JuiAssets.DonePrev: ImageVector
+ get() {
+ if (_donePrev != null) {
+ return _donePrev!!
+ }
+ _donePrev = Builder(name = "DonePrev", defaultWidth = 24.0.dp, defaultHeight = 24.0.dp,
+ viewportWidth = 24.0f, viewportHeight = 24.0f).apply {
+ path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
+ strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
+ pathFillType = NonZero) {
+ moveTo(9.0f, 16.2f)
+ lineTo(4.8f, 12.0f)
+ lineToRelative(-1.4f, 1.4f)
+ lineTo(9.0f, 19.0f)
+ lineTo(21.0f, 7.0f)
+ lineToRelative(-1.4f, -1.4f)
+ lineTo(9.0f, 16.2f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
+ strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
+ pathFillType = NonZero) {
+ moveTo(22.0f, 18.0f)
+ lineToRelative(-3.0f, 0.0f)
+ lineToRelative(0.0f, -4.0f)
+ lineToRelative(-2.0f, 0.0f)
+ lineToRelative(0.0f, 4.0f)
+ lineToRelative(-3.0f, 0.0f)
+ lineToRelative(4.0f, 4.0f)
+ close()
+ }
+ }
+ .build()
+ return _donePrev!!
+ }
+
+private var _donePrev: ImageVector? = null
diff --git a/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/__Ca.kt b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/__Ca.kt
new file mode 100644
index 00000000..1463f490
--- /dev/null
+++ b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/__Ca.kt
@@ -0,0 +1,23 @@
+package ca.gosyer.jui.uicore.icons.juiassets
+
+import androidx.compose.ui.graphics.vector.ImageVector
+import ca.gosyer.jui.uicore.icons.JuiAssets
+import ca.gosyer.jui.uicore.icons.juiassets.ca.AllAssets
+import ca.gosyer.jui.uicore.icons.juiassets.ca.Gosyer
+import kotlin.collections.List as ____KtList
+
+public object CaGroup
+
+public val JuiAssets.Ca: CaGroup
+ get() = CaGroup
+
+private var __AllAssets: ____KtList? = null
+
+public val CaGroup.AllAssets: ____KtList
+ get() {
+ if (__AllAssets != null) {
+ return __AllAssets!!
+ }
+ __AllAssets= Gosyer.AllAssets + listOf()
+ return __AllAssets!!
+ }
diff --git a/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/__Gosyer.kt b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/__Gosyer.kt
new file mode 100644
index 00000000..e90470e4
--- /dev/null
+++ b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/__Gosyer.kt
@@ -0,0 +1,23 @@
+package ca.gosyer.jui.uicore.icons.juiassets.ca
+
+import androidx.compose.ui.graphics.vector.ImageVector
+import ca.gosyer.jui.uicore.icons.juiassets.CaGroup
+import ca.gosyer.jui.uicore.icons.juiassets.ca.gosyer.AllAssets
+import ca.gosyer.jui.uicore.icons.juiassets.ca.gosyer.Jui
+import kotlin.collections.List as ____KtList
+
+public object GosyerGroup
+
+public val CaGroup.Gosyer: GosyerGroup
+ get() = GosyerGroup
+
+private var __AllAssets: ____KtList? = null
+
+public val GosyerGroup.AllAssets: ____KtList
+ get() {
+ if (__AllAssets != null) {
+ return __AllAssets!!
+ }
+ __AllAssets= Jui.AllAssets + listOf()
+ return __AllAssets!!
+ }
diff --git a/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/__Jui.kt b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/__Jui.kt
new file mode 100644
index 00000000..192e86a5
--- /dev/null
+++ b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/__Jui.kt
@@ -0,0 +1,23 @@
+package ca.gosyer.jui.uicore.icons.juiassets.ca.gosyer
+
+import androidx.compose.ui.graphics.vector.ImageVector
+import ca.gosyer.jui.uicore.icons.juiassets.ca.GosyerGroup
+import ca.gosyer.jui.uicore.icons.juiassets.ca.gosyer.jui.AllAssets
+import ca.gosyer.jui.uicore.icons.juiassets.ca.gosyer.jui.Uicore
+import kotlin.collections.List as ____KtList
+
+public object JuiGroup
+
+public val GosyerGroup.Jui: JuiGroup
+ get() = JuiGroup
+
+private var __AllAssets: ____KtList? = null
+
+public val JuiGroup.AllAssets: ____KtList
+ get() {
+ if (__AllAssets != null) {
+ return __AllAssets!!
+ }
+ __AllAssets= Uicore.AllAssets + listOf()
+ return __AllAssets!!
+ }
diff --git a/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/jui/__Uicore.kt b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/jui/__Uicore.kt
new file mode 100644
index 00000000..5c54dd3b
--- /dev/null
+++ b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/jui/__Uicore.kt
@@ -0,0 +1,23 @@
+package ca.gosyer.jui.uicore.icons.juiassets.ca.gosyer.jui
+
+import androidx.compose.ui.graphics.vector.ImageVector
+import ca.gosyer.jui.uicore.icons.juiassets.ca.gosyer.JuiGroup
+import ca.gosyer.jui.uicore.icons.juiassets.ca.gosyer.jui.uicore.AllAssets
+import ca.gosyer.jui.uicore.icons.juiassets.ca.gosyer.jui.uicore.Icons
+import kotlin.collections.List as ____KtList
+
+public object UicoreGroup
+
+public val JuiGroup.Uicore: UicoreGroup
+ get() = UicoreGroup
+
+private var __AllAssets: ____KtList? = null
+
+public val UicoreGroup.AllAssets: ____KtList
+ get() {
+ if (__AllAssets != null) {
+ return __AllAssets!!
+ }
+ __AllAssets= Icons.AllAssets + listOf()
+ return __AllAssets!!
+ }
diff --git a/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/jui/uicore/__Icons.kt b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/jui/uicore/__Icons.kt
new file mode 100644
index 00000000..2db26823
--- /dev/null
+++ b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/jui/uicore/__Icons.kt
@@ -0,0 +1,23 @@
+package ca.gosyer.jui.uicore.icons.juiassets.ca.gosyer.jui.uicore
+
+import androidx.compose.ui.graphics.vector.ImageVector
+import ca.gosyer.jui.uicore.icons.juiassets.ca.gosyer.jui.UicoreGroup
+import ca.gosyer.jui.uicore.icons.juiassets.ca.gosyer.jui.uicore.icons.AllAssets
+import ca.gosyer.jui.uicore.icons.juiassets.ca.gosyer.jui.uicore.icons.Juiassets
+import kotlin.collections.List as ____KtList
+
+public object IconsGroup
+
+public val UicoreGroup.Icons: IconsGroup
+ get() = IconsGroup
+
+private var __AllAssets: ____KtList? = null
+
+public val IconsGroup.AllAssets: ____KtList
+ get() {
+ if (__AllAssets != null) {
+ return __AllAssets!!
+ }
+ __AllAssets= Juiassets.AllAssets + listOf()
+ return __AllAssets!!
+ }
diff --git a/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/jui/uicore/icons/__Juiassets.kt b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/jui/uicore/icons/__Juiassets.kt
new file mode 100644
index 00000000..208ff262
--- /dev/null
+++ b/ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/jui/uicore/icons/__Juiassets.kt
@@ -0,0 +1,21 @@
+package ca.gosyer.jui.uicore.icons.juiassets.ca.gosyer.jui.uicore.icons
+
+import androidx.compose.ui.graphics.vector.ImageVector
+import ca.gosyer.jui.uicore.icons.juiassets.ca.gosyer.jui.uicore.IconsGroup
+import kotlin.collections.List as ____KtList
+
+public object JuiassetsGroup
+
+public val IconsGroup.Juiassets: JuiassetsGroup
+ get() = JuiassetsGroup
+
+private var __AllAssets: ____KtList? = null
+
+public val JuiassetsGroup.AllAssets: ____KtList
+ get() {
+ if (__AllAssets != null) {
+ return __AllAssets!!
+ }
+ __AllAssets= listOf()
+ return __AllAssets!!
+ }
diff --git a/ui-core/src/desktopMain/kotlin/ca/gosyer/jui/uicore/components/DesktopBottomActionMenu.kt b/ui-core/src/desktopMain/kotlin/ca/gosyer/jui/uicore/components/DesktopBottomActionMenu.kt
new file mode 100644
index 00000000..e69feafa
--- /dev/null
+++ b/ui-core/src/desktopMain/kotlin/ca/gosyer/jui/uicore/components/DesktopBottomActionMenu.kt
@@ -0,0 +1,42 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package ca.gosyer.jui.uicore.components
+
+import androidx.compose.foundation.hoverable
+import androidx.compose.foundation.interaction.HoverInteraction
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.onClick
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.launch
+import kotlin.time.Duration.Companion.seconds
+
+actual fun Modifier.buttonModifier(
+ onClick: () -> Unit,
+ onHintClick: () -> Unit,
+): Modifier = composed {
+ val interactionSource = remember { MutableInteractionSource() }
+ LaunchedEffect(interactionSource) {
+ launch {
+ interactionSource.interactions
+ .mapLatest {
+ if (it !is HoverInteraction.Enter) return@mapLatest
+ delay(2.seconds)
+ onHintClick()
+ }
+ .collect()
+ }
+ }
+ interactionSource.interactions
+ onClick(onClick = onClick)
+ .hoverable(interactionSource)
+}
\ No newline at end of file
diff --git a/ui-core/src/iosMain/kotlin/ca/gosyer/jui/uicore/components/IosBottomActionMenu.kt b/ui-core/src/iosMain/kotlin/ca/gosyer/jui/uicore/components/IosBottomActionMenu.kt
new file mode 100644
index 00000000..6fb4f48b
--- /dev/null
+++ b/ui-core/src/iosMain/kotlin/ca/gosyer/jui/uicore/components/IosBottomActionMenu.kt
@@ -0,0 +1,26 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package ca.gosyer.jui.uicore.components
+
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.material.ripple.rememberRipple
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+
+actual fun Modifier.buttonModifier(
+ onClick: () -> Unit,
+ onHintClick: () -> Unit,
+): Modifier = composed {
+ combinedClickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = rememberRipple(bounded = false),
+ onLongClick = onHintClick,
+ onClick = onClick,
+ )
+}
\ No newline at end of file