From 1ad0c8f3eb405bcce540dcb11cafbe02817e8d78 Mon Sep 17 00:00:00 2001 From: Syer10 Date: Sun, 6 Nov 2022 21:08:14 -0500 Subject: [PATCH] Batch manga download chapters, bump minimum Tachidesk --- buildSrc/src/main/kotlin/Config.kt | 6 +- .../resources/MR/values/base/strings.xml | 17 +- .../ui/manga/components/AndroidChapterItem.kt | 53 +--- .../ui/base/chapter/ChapterDownloadButtons.kt | 23 +- .../jui/ui/base/navigation/ActionMenu.kt | 85 +++++-- .../gosyer/jui/ui/base/navigation/Toolbar.kt | 17 +- .../ca/gosyer/jui/ui/manga/MangaScreen.kt | 17 +- .../jui/ui/manga/MangaScreenViewModel.kt | 98 +++++++- .../jui/ui/manga/components/ChapterItem.kt | 163 ++++++++----- .../ui/manga/components/MangaScreenContent.kt | 227 ++++++++++++++++-- .../ui/manga/components/DesktopChapterItem.kt | 40 ++- .../components/AndroidBottomActionMenu.kt | 26 ++ .../jui/uicore/components/BottomActionMenu.kt | 151 ++++++++++++ .../uicore/components/SelectedBackground.kt | 22 ++ .../ca/gosyer/jui/uicore/icons/__JuiAssets.kt | 20 ++ .../jui/uicore/icons/juiassets/DonePrev.kt | 53 ++++ .../gosyer/jui/uicore/icons/juiassets/__Ca.kt | 23 ++ .../jui/uicore/icons/juiassets/ca/__Gosyer.kt | 23 ++ .../uicore/icons/juiassets/ca/gosyer/__Jui.kt | 23 ++ .../icons/juiassets/ca/gosyer/jui/__Uicore.kt | 23 ++ .../juiassets/ca/gosyer/jui/uicore/__Icons.kt | 23 ++ .../ca/gosyer/jui/uicore/icons/__Juiassets.kt | 21 ++ .../components/DesktopBottomActionMenu.kt | 42 ++++ .../uicore/components/IosBottomActionMenu.kt | 26 ++ 24 files changed, 1043 insertions(+), 179 deletions(-) create mode 100644 ui-core/src/androidMain/kotlin/ca/gosyer/jui/uicore/components/AndroidBottomActionMenu.kt create mode 100644 ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/components/BottomActionMenu.kt create mode 100644 ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/components/SelectedBackground.kt create mode 100644 ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/__JuiAssets.kt create mode 100644 ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/DonePrev.kt create mode 100644 ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/__Ca.kt create mode 100644 ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/__Gosyer.kt create mode 100644 ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/__Jui.kt create mode 100644 ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/jui/__Uicore.kt create mode 100644 ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/jui/uicore/__Icons.kt create mode 100644 ui-core/src/commonMain/kotlin/ca/gosyer/jui/uicore/icons/juiassets/ca/gosyer/jui/uicore/icons/__Juiassets.kt create mode 100644 ui-core/src/desktopMain/kotlin/ca/gosyer/jui/uicore/components/DesktopBottomActionMenu.kt create mode 100644 ui-core/src/iosMain/kotlin/ca/gosyer/jui/uicore/components/IosBottomActionMenu.kt 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