Batch manga download chapters, bump minimum Tachidesk

This commit is contained in:
Syer10
2022-11-06 21:08:14 -05:00
parent e1123dcffd
commit 1ad0c8f3eb
24 changed files with 1043 additions and 179 deletions

View File

@@ -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

View File

@@ -32,9 +32,6 @@
<string name="action_install">Install</string>
<string name="action_uninstall">Uninstall</string>
<string name="action_update">Update</string>
<string name="action_toggle_read">Toggle read</string>
<string name="action_mark_previous_read">Mark previous as read</string>
<string name="action_toggle_bookmarked">Toggle bookmarked</string>
<string name="action_favorite">Favorite</string>
<string name="action_remove_favorite">Unfavorite</string>
<string name="action_refresh_manga">Refresh</string>
@@ -46,6 +43,15 @@
<string name="action_ok">Ok</string>
<string name="action_browser">Browser</string>
<string name="action_filter">Filter</string>
<string name="action_mark_as_read">Mark as read</string>
<string name="action_mark_as_unread">Mark as unread</string>
<string name="action_download">Download</string>
<string name="action_bookmark">Bookmark chapter</string>
<string name="action_remove_bookmark">Unbookmark chapter</string>
<string name="action_mark_previous_read">Mark previous as read</string>
<string name="action_filter_bookmarked">Bookmarked</string>
<string name="action_select_all">Select all</string>
<string name="action_select_inverse">Select inverse</string>
<!-- Locations -->
<string name="location_library">Library</string>
@@ -107,6 +113,11 @@
<string name="failed_manga_fetch">Failed to load manga</string>
<string name="edit_categories">Categories</string>
<string name="select_categories">Select Categories</string>
<string name="download_1">Next chapter</string>
<string name="download_5">Next 5 chapters</string>
<string name="download_10">Next 10 chapters</string>
<string name="download_all">All</string>
<string name="download_unread">Unread</string>
<string name="status_unknown">Unknown</string>
<string name="status_ongoing">Ongoing</string>

View File

@@ -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
)

View File

@@ -49,16 +49,19 @@ import kotlinx.coroutines.flow.asStateFlow
data class ChapterDownloadItem(
val manga: Manga?,
val chapter: Chapter,
private val _downloadState: MutableStateFlow<ChapterDownloadState> = MutableStateFlow(
if (chapter.downloaded) {
ChapterDownloadState.Downloaded
} else {
ChapterDownloadState.NotDownloaded
}
),
private val _downloadChapterFlow: MutableStateFlow<DownloadChapter?> = MutableStateFlow(null)
) {
private val _isSelected = MutableStateFlow(false)
val isSelected = _isSelected.asStateFlow()
private val _downloadState: MutableStateFlow<ChapterDownloadState> = MutableStateFlow(
when (chapter.downloaded) {
true -> ChapterDownloadState.Downloaded
false -> ChapterDownloadState.NotDownloaded
}
)
val downloadState = _downloadState.asStateFlow()
private val _downloadChapterFlow: MutableStateFlow<DownloadChapter?> = MutableStateFlow(null)
val downloadChapterFlow = _downloadChapterFlow.asStateFlow()
fun updateFrom(downloadingChapters: List<DownloadChapter>) {
@@ -83,6 +86,10 @@ data class ChapterDownloadItem(
stopChapterDownload.await(chapter)
_downloadState.value = ChapterDownloadState.NotDownloaded
}
fun isSelected(selectedItems: List<Long>): Boolean {
return (chapter.id in selectedItems).also { _isSelected.value = it }
}
}
enum class ChapterDownloadState {

View File

@@ -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>
) : 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<ActionItem>,
items: ImmutableList<Action>,
numIcons: Int = 3, // includes overflow menu icon; may be overridden by NEVER_OVERFLOW
menuVisible: MutableState<Boolean> = 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<ActionGroup?>(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<ActionItem>,
items: ImmutableList<Action>,
numIcons: Int
): Pair<List<ActionItem>, List<ActionItem>> {
): Pair<List<Action>, List<Action>> {
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<ActionItem>()
val overflowActions = ArrayList<ActionItem>()
val iconActions = ArrayList<Action>()
val overflowActions = ArrayList<Action>()
var iconsAvailableBeforeOverflow = actionIconSpace - iconCount
for (item in items) {

View File

@@ -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<ActionItem> = { remember { persistentListOf() } },
actions: @Composable () -> ImmutableList<Action> = { 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<ActionItem> = { remember { persistentListOf() } },
actions: @Composable () -> ImmutableList<Action> = { 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<ActionItem> = { remember { persistentListOf() } },
actions: @Composable () -> ImmutableList<Action> = { 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)
)
}

View File

@@ -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
)

View File

@@ -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<ImmutableList<ChapterDownloadItem>>(persistentListOf())
val chapters = _chapters.asStateFlow()
private val _selectedIds = MutableStateFlow<ImmutableList<Long>>(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<Unit>()
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<Chapter>.toDownloadChapters() = map {
ChapterDownloadItem(null, it)
}.toImmutableList()

View File

@@ -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) }
)
}
}
}

View File

@@ -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<SharedFlow<Unit>>,
availableCategories: ImmutableList<Category>,
mangaCategories: ImmutableList<Category>,
inActionMode: Boolean,
selectedItems: ImmutableList<ChapterDownloadItem>,
addFavorite: (List<Category>, List<Category>) -> 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<ActionItem> {
openInBrowser: () -> Unit,
downloadNext: (Int) -> Unit,
downloadUnread: () -> Unit,
downloadAll: () -> Unit
): ImmutableList<Action> {
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<ActionItem> {
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<ChapterDownloadItem>,
markRead: (Int) -> Unit,
markUnread: (Int) -> Unit,
bookmarkChapter: (Int) -> Unit,
unBookmarkChapter: (Int) -> Unit,
markPreviousAsRead: (Int) -> Unit,
deleteChapter: (Int) -> Unit,
downloadChapters: () -> Unit
): ImmutableList<BottomActionItem> {
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()
}

View File

@@ -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<ContextMenuItem> {
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)
)
}

View File

@@ -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,
)
}

View File

@@ -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<BottomActionItem>,
) {
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<Int?>(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()
}
}

View File

@@ -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
}
}

View File

@@ -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<ImageVector>? = null
public val JuiAssets.AllAssets: ____KtList<ImageVector>
get() {
if (__AllAssets != null) {
return __AllAssets!!
}
__AllAssets= Ca.AllAssets + listOf(DonePrev)
return __AllAssets!!
}

View File

@@ -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

View File

@@ -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<ImageVector>? = null
public val CaGroup.AllAssets: ____KtList<ImageVector>
get() {
if (__AllAssets != null) {
return __AllAssets!!
}
__AllAssets= Gosyer.AllAssets + listOf()
return __AllAssets!!
}

View File

@@ -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<ImageVector>? = null
public val GosyerGroup.AllAssets: ____KtList<ImageVector>
get() {
if (__AllAssets != null) {
return __AllAssets!!
}
__AllAssets= Jui.AllAssets + listOf()
return __AllAssets!!
}

View File

@@ -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<ImageVector>? = null
public val JuiGroup.AllAssets: ____KtList<ImageVector>
get() {
if (__AllAssets != null) {
return __AllAssets!!
}
__AllAssets= Uicore.AllAssets + listOf()
return __AllAssets!!
}

View File

@@ -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<ImageVector>? = null
public val UicoreGroup.AllAssets: ____KtList<ImageVector>
get() {
if (__AllAssets != null) {
return __AllAssets!!
}
__AllAssets= Icons.AllAssets + listOf()
return __AllAssets!!
}

View File

@@ -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<ImageVector>? = null
public val IconsGroup.AllAssets: ____KtList<ImageVector>
get() {
if (__AllAssets != null) {
return __AllAssets!!
}
__AllAssets= Juiassets.AllAssets + listOf()
return __AllAssets!!
}

View File

@@ -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<ImageVector>? = null
public val JuiassetsGroup.AllAssets: ____KtList<ImageVector>
get() {
if (__AllAssets != null) {
return __AllAssets!!
}
__AllAssets= listOf()
return __AllAssets!!
}

View File

@@ -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)
}

View File

@@ -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,
)
}