From 51e8718c290d8cdd04076e3e2b42bb192306f105 Mon Sep 17 00:00:00 2001 From: Syer10 Date: Tue, 15 Jun 2021 16:54:13 -0400 Subject: [PATCH] Initial downloads menu implementation --- src/main/kotlin/ca/gosyer/data/DataModule.kt | 6 + .../gosyer/data/download/DownloadService.kt | 5 + .../data/download/model/DownloadChapter.kt | 8 +- .../base/components/BoxWithTooltipSurface.kt | 46 ++++ .../base/components/CombinedMouseClickable.kt | 2 +- .../ui/base/components/DropdownIconButton.kt | 67 ++++++ .../ca/gosyer/ui/base/components/Toolbar.kt | 20 +- .../ca/gosyer/ui/downloads/DownloadsMenu.kt | 144 ++++++++++++ .../ui/downloads/DownloadsMenuViewModel.kt | 57 +++++ .../ca/gosyer/ui/extensions/ExtensionsMenu.kt | 8 +- .../ca/gosyer/ui/library/LibraryScreen.kt | 82 +++---- src/main/kotlin/ca/gosyer/ui/main/MainMenu.kt | 211 +++++++++++++----- .../kotlin/ca/gosyer/ui/manga/ChapterItem.kt | 78 ++----- .../ca/gosyer/ui/sources/SourcesMenu.kt | 12 +- 14 files changed, 567 insertions(+), 179 deletions(-) create mode 100644 src/main/kotlin/ca/gosyer/ui/base/components/BoxWithTooltipSurface.kt create mode 100644 src/main/kotlin/ca/gosyer/ui/base/components/DropdownIconButton.kt create mode 100644 src/main/kotlin/ca/gosyer/ui/downloads/DownloadsMenu.kt create mode 100644 src/main/kotlin/ca/gosyer/ui/downloads/DownloadsMenuViewModel.kt diff --git a/src/main/kotlin/ca/gosyer/data/DataModule.kt b/src/main/kotlin/ca/gosyer/data/DataModule.kt index 81065ce6..f1574fbb 100644 --- a/src/main/kotlin/ca/gosyer/data/DataModule.kt +++ b/src/main/kotlin/ca/gosyer/data/DataModule.kt @@ -16,8 +16,10 @@ import ca.gosyer.data.server.Http import ca.gosyer.data.server.HttpProvider import ca.gosyer.data.server.ServerPreferences import ca.gosyer.data.server.ServerService +import ca.gosyer.data.server.interactions.BackupInteractionHandler import ca.gosyer.data.server.interactions.CategoryInteractionHandler import ca.gosyer.data.server.interactions.ChapterInteractionHandler +import ca.gosyer.data.server.interactions.DownloadInteractionHandler import ca.gosyer.data.server.interactions.ExtensionInteractionHandler import ca.gosyer.data.server.interactions.LibraryInteractionHandler import ca.gosyer.data.server.interactions.MangaInteractionHandler @@ -58,10 +60,14 @@ val DataModule = module { .toProvider(HttpProvider::class) .providesSingleton() + bind() + .toClass() bind() .toClass() bind() .toClass() + bind() + .toClass() bind() .toClass() bind() diff --git a/src/main/kotlin/ca/gosyer/data/download/DownloadService.kt b/src/main/kotlin/ca/gosyer/data/download/DownloadService.kt index 4d219fad..46525845 100644 --- a/src/main/kotlin/ca/gosyer/data/download/DownloadService.kt +++ b/src/main/kotlin/ca/gosyer/data/download/DownloadService.kt @@ -40,6 +40,10 @@ class DownloadService @Inject constructor( private val serverUrl = serverPreferences.serverUrl().stateIn(GlobalScope) private val _downloaderStatus = MutableStateFlow(DownloaderStatus.Stopped) val downloaderStatus = _downloaderStatus.asStateFlow() + + private val _downloadQueue = MutableStateFlow(emptyList()) + val downloadQueue = _downloadQueue.asStateFlow() + private val watching = mutableMapOf>>() init { @@ -58,6 +62,7 @@ class DownloadService @Inject constructor( frame as Frame.Text val status = json.decodeFromString(frame.readText()) _downloaderStatus.value = status.status + _downloadQueue.value = status.queue val queue = status.queue.groupBy { it.mangaId } watching.forEach { (mangaId, flow) -> flow.emit(queue[mangaId].orEmpty()) diff --git a/src/main/kotlin/ca/gosyer/data/download/model/DownloadChapter.kt b/src/main/kotlin/ca/gosyer/data/download/model/DownloadChapter.kt index d2457f07..2698f781 100644 --- a/src/main/kotlin/ca/gosyer/data/download/model/DownloadChapter.kt +++ b/src/main/kotlin/ca/gosyer/data/download/model/DownloadChapter.kt @@ -13,8 +13,8 @@ import kotlinx.serialization.Serializable data class DownloadChapter( val chapterIndex: Int, val mangaId: Long, - var state: DownloadState = DownloadState.Queued, - var progress: Float = 0f, - var tries: Int = 0, - var chapter: Chapter? = null, + val state: DownloadState = DownloadState.Queued, + val progress: Float = 0f, + val tries: Int = 0, + val chapter: Chapter? = null, ) diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/BoxWithTooltipSurface.kt b/src/main/kotlin/ca/gosyer/ui/base/components/BoxWithTooltipSurface.kt new file mode 100644 index 00000000..3c4724ba --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/components/BoxWithTooltipSurface.kt @@ -0,0 +1,46 @@ +/* + * 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.ui.base.components + +import androidx.compose.foundation.BoxWithTooltip +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp + +@Composable +fun BoxWithTooltipSurface( + tooltip: @Composable () -> Unit, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + propagateMinConstraints: Boolean = false, + delay: Int = 500, + offset: DpOffset = DpOffset.Zero, + content: @Composable BoxScope.() -> Unit +) { + BoxWithTooltip( + { + Surface( + modifier = Modifier.shadow(4.dp), + shape = RoundedCornerShape(4.dp), + elevation = 4.dp, + content = tooltip + ) + }, + modifier, + contentAlignment, + propagateMinConstraints, + delay, + offset, + content + ) +} diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/CombinedMouseClickable.kt b/src/main/kotlin/ca/gosyer/ui/base/components/CombinedMouseClickable.kt index 38a1a03a..841f205d 100644 --- a/src/main/kotlin/ca/gosyer/ui/base/components/CombinedMouseClickable.kt +++ b/src/main/kotlin/ca/gosyer/ui/base/components/CombinedMouseClickable.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.IntOffset import java.awt.event.MouseEvent -suspend fun AwaitPointerEventScope.awaitEventFirstDown(): PointerEvent { +internal suspend fun AwaitPointerEventScope.awaitEventFirstDown(): PointerEvent { var event: PointerEvent do { event = awaitPointerEvent() diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/DropdownIconButton.kt b/src/main/kotlin/ca/gosyer/ui/base/components/DropdownIconButton.kt new file mode 100644 index 00000000..a6c5840f --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/components/DropdownIconButton.kt @@ -0,0 +1,67 @@ +/* + * 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.ui.base.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.size +import androidx.compose.material.DropdownMenu +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +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.input.pointer.pointerInput +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp + +@Composable +fun DropdownIconButton( + key: Any? = Unit, + dropdownItems: @Composable ColumnScope.() -> Unit, + content: @Composable BoxScope.() -> Unit +) { + var showMenu by remember(key) { mutableStateOf(false) } + var offset by remember(key) { mutableStateOf(DpOffset(0.dp, 0.dp)) } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + offset = offset, + content = dropdownItems + ) + Box( + modifier = Modifier.fillMaxHeight() + .size(48.dp) + .clickable( + remember { MutableInteractionSource() }, + role = Role.Button, + indication = rememberRipple(bounded = false, radius = 24.dp) + ) { + showMenu = true + } + .pointerInput(Unit) { + forEachGesture { + awaitPointerEventScope { + awaitEventFirstDown().mouseEvent?.let { + offset = DpOffset(it.x.dp, it.y.dp) + } + } + } + }, + contentAlignment = Alignment.Center, + content = content + ) +} diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/Toolbar.kt b/src/main/kotlin/ca/gosyer/ui/base/components/Toolbar.kt index 3517e996..97fa02bf 100644 --- a/src/main/kotlin/ca/gosyer/ui/base/components/Toolbar.kt +++ b/src/main/kotlin/ca/gosyer/ui/base/components/Toolbar.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -94,13 +95,22 @@ fun Toolbar( Row { actions() if (closable) { - IconButton( - onClick = onClose - ) { - Icon(Icons.Default.Close, "close", Modifier.size(52.dp)) - } + ActionIcon(onClick = onClose, "Close", Icons.Default.Close) } } } } } + +@Composable +fun ActionIcon(onClick: () -> Unit, contentDescription: String, icon: ImageVector) { + BoxWithTooltipSurface( + { + Text(contentDescription, modifier = Modifier.padding(10.dp)) + } + ) { + IconButton(onClick = onClick) { + Icon(icon, contentDescription, Modifier.size(52.dp)) + } + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/downloads/DownloadsMenu.kt b/src/main/kotlin/ca/gosyer/ui/downloads/DownloadsMenu.kt new file mode 100644 index 00000000..83ad69f4 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/downloads/DownloadsMenu.kt @@ -0,0 +1,144 @@ +/* + * 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.ui.downloads + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.ContentAlpha +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ClearAll +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import ca.gosyer.BuildConfig +import ca.gosyer.data.download.model.DownloadChapter +import ca.gosyer.data.download.model.DownloaderStatus +import ca.gosyer.data.models.Chapter +import ca.gosyer.ui.base.components.ActionIcon +import ca.gosyer.ui.base.components.DropdownIconButton +import ca.gosyer.ui.base.components.Toolbar +import ca.gosyer.ui.base.vm.viewModel +import ca.gosyer.util.compose.ThemedWindow + +fun openDownloadsMenu() { + ThemedWindow(BuildConfig.NAME) { + DownloadsMenu() + } +} + +@Composable +fun DownloadsMenu() { + val vm = viewModel() + val downloadQueue by vm.downloadQueue.collectAsState() + + Surface { + Column { + Toolbar( + "Downloads", + closable = false, + actions = { + val downloadStatus by vm.downloaderStatus.collectAsState() + if (downloadStatus == DownloaderStatus.Started) { + ActionIcon(onClick = vm::pause, "Pause", Icons.Default.Pause) + } else { + ActionIcon(onClick = vm::start, "Continue", Icons.Default.PlayArrow) + } + ActionIcon(onClick = vm::clear, "Clear queue", Icons.Default.ClearAll) + } + ) + LazyColumn(Modifier.fillMaxSize()) { + items(downloadQueue) { + downloadsItem( + it, + vm::stopDownload, + vm::moveToBottom + ) + } + } + } + } +} + +@Composable +private fun downloadsItem( + chapter: DownloadChapter, + onDownloadCancel: (Chapter?) -> Unit, + onMoveDownloadToBottom: (Chapter?) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth() + .height(56.dp) + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceAround + ) { + Column(Modifier.fillMaxHeight(), verticalArrangement = Arrangement.SpaceAround) { + Row(Modifier.fillMaxWidth().padding(horizontal = 32.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text(chapter.chapter?.name.toString(), maxLines = 1, overflow = TextOverflow.Ellipsis) + // Spacer(Modifier.width(16.dp)) + if (chapter.chapter?.pageCount != null && chapter.chapter.pageCount != -1) { + Text( + "${(chapter.chapter.pageCount * chapter.progress).toInt()}/${chapter.chapter.pageCount}", + Modifier.padding(start = 16.dp).requiredWidth(IntrinsicSize.Max), + style = MaterialTheme.typography.body2, + color = LocalContentColor.current.copy(alpha = ContentAlpha.disabled), + maxLines = 1, + overflow = TextOverflow.Visible + ) + } else { + Spacer(Modifier.width(32.dp)) + } + } + Spacer(Modifier.height(4.dp)) + LinearProgressIndicator( + chapter.progress, + Modifier.fillMaxWidth() + .padding(start = 32.dp, end = 16.dp, bottom = 8.dp) + ) + } + DropdownIconButton( + chapter.mangaId to chapter.chapterIndex, + { + DropdownMenuItem(onClick = { onDownloadCancel(chapter.chapter) }) { + Text("Cancel") + } + DropdownMenuItem(onClick = { onMoveDownloadToBottom(chapter.chapter) }) { + Text("Move to bottom") + } + } + ) { + Icon( + Icons.Default.MoreVert, + null + ) + } + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/downloads/DownloadsMenuViewModel.kt b/src/main/kotlin/ca/gosyer/ui/downloads/DownloadsMenuViewModel.kt new file mode 100644 index 00000000..8d0ca4f7 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/downloads/DownloadsMenuViewModel.kt @@ -0,0 +1,57 @@ +/* + * 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.ui.downloads + +import ca.gosyer.data.download.DownloadService +import ca.gosyer.data.models.Chapter +import ca.gosyer.data.server.interactions.ChapterInteractionHandler +import ca.gosyer.data.server.interactions.DownloadInteractionHandler +import ca.gosyer.ui.base.vm.ViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +class DownloadsMenuViewModel @Inject constructor( + private val downloadService: DownloadService, + private val downloadsHandler: DownloadInteractionHandler, + private val chapterHandler: ChapterInteractionHandler +) : ViewModel() { + val downloaderStatus get() = downloadService.downloaderStatus + val downloadQueue get() = downloadService.downloadQueue + + fun start() { + scope.launch { + downloadsHandler.startDownloading() + } + } + + fun pause() { + scope.launch { + downloadsHandler.stopDownloading() + } + } + + fun clear() { + scope.launch { + downloadsHandler.clearDownloadQueue() + } + } + + fun stopDownload(chapter: Chapter?) { + chapter ?: return + scope.launch { + chapterHandler.deleteChapterDownload(chapter) + } + } + + fun moveToBottom(chapter: Chapter?) { + chapter ?: return + scope.launch { + chapterHandler.deleteChapterDownload(chapter) + chapterHandler.queueChapterDownload(chapter) + } + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenu.kt b/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenu.kt index f06c860f..05f0c37b 100644 --- a/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenu.kt @@ -6,7 +6,6 @@ package ca.gosyer.ui.extensions -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -26,6 +25,7 @@ import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.material.Button import androidx.compose.material.ContentAlpha import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -39,6 +39,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import ca.gosyer.BuildConfig import ca.gosyer.data.models.Extension import ca.gosyer.ui.base.components.KtorImage import ca.gosyer.ui.base.components.LoadingScreen @@ -49,12 +50,11 @@ import ca.gosyer.util.compose.persistentLazyListState import java.util.Locale fun openExtensionsMenu() { - ThemedWindow(title = "TachideskJUI - Extensions", size = IntSize(550, 700)) { + ThemedWindow(BuildConfig.NAME, size = IntSize(550, 700)) { ExtensionsMenu() } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun ExtensionsMenu() { val vm = viewModel() @@ -63,7 +63,7 @@ fun ExtensionsMenu() { val serverUrl by vm.serverUrl.collectAsState() val search by vm.searchQuery.collectAsState() - Box(Modifier.fillMaxSize().background(MaterialTheme.colors.background)) { + Surface(Modifier.fillMaxSize()) { if (isLoading) { LoadingScreen(isLoading) } else { diff --git a/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt b/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt index f69bc09d..adeb67ee 100644 --- a/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt +++ b/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.ScrollableTabRow +import androidx.compose.material.Surface import androidx.compose.material.Tab import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -24,6 +25,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed +import ca.gosyer.BuildConfig import ca.gosyer.data.library.model.DisplayMode import ca.gosyer.data.models.Category import ca.gosyer.data.models.Manga @@ -35,7 +37,7 @@ import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.PagerState fun openLibraryMenu() { - ThemedWindow { + ThemedWindow(BuildConfig.NAME) { LibraryScreen() } } @@ -50,46 +52,48 @@ fun LibraryScreen(onClickManga: (Long) -> Unit = { openMangaMenu(it) }) { val serverUrl by vm.serverUrl.collectAsState() // val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) - if (categories.isEmpty()) { - LoadingScreen(isLoading) - } else { - /*ModalBottomSheetLayout( - sheetState = sheetState, - sheetContent = { *//*LibrarySheet()*//* } - ) {*/ - Column(Modifier.fillMaxWidth()) { - /*Toolbar( - title = { - val text = if (vm.showCategoryTabs) { - stringResource(R.string.library_label) - } else { - vm.selectedCategory?.visibleName.orEmpty() + Surface { + if (categories.isEmpty()) { + LoadingScreen(isLoading) + } else { + /*ModalBottomSheetLayout( + sheetState = sheetState, + sheetContent = { *//*LibrarySheet()*//* } + ) {*/ + Column(Modifier.fillMaxWidth()) { + /*Toolbar( + title = { + val text = if (vm.showCategoryTabs) { + stringResource(R.string.library_label) + } else { + vm.selectedCategory?.visibleName.orEmpty() + } + Text(text) + }, + actions = { + IconButton(onClick = { scope.launch { sheetState.show() }}) { + Icon(Icons.Default.FilterList, contentDescription = null) + } } - Text(text) - }, - actions = { - IconButton(onClick = { scope.launch { sheetState.show() }}) { - Icon(Icons.Default.FilterList, contentDescription = null) - } - } - )*/ - LibraryTabs( - visible = true, // vm.showCategoryTabs, - categories = categories, - selectedPage = selectedCategoryIndex, - onPageChanged = vm::setSelectedPage - ) - LibraryPager( - categories = categories, - displayMode = displayMode, - selectedPage = selectedCategoryIndex, - serverUrl = serverUrl, - getLibraryForPage = { vm.getLibraryForCategoryIndex(it).collectAsState() }, - onPageChanged = { vm.setSelectedPage(it) }, - onClickManga = onClickManga - ) + )*/ + LibraryTabs( + visible = true, // vm.showCategoryTabs, + categories = categories, + selectedPage = selectedCategoryIndex, + onPageChanged = vm::setSelectedPage + ) + LibraryPager( + categories = categories, + displayMode = displayMode, + selectedPage = selectedCategoryIndex, + serverUrl = serverUrl, + getLibraryForPage = { vm.getLibraryForCategoryIndex(it).collectAsState() }, + onPageChanged = { vm.setSelectedPage(it) }, + onClickManga = onClickManga + ) + } + // } } - // } } } diff --git a/src/main/kotlin/ca/gosyer/ui/main/MainMenu.kt b/src/main/kotlin/ca/gosyer/ui/main/MainMenu.kt index 7d4cffd4..14471f11 100644 --- a/src/main/kotlin/ca/gosyer/ui/main/MainMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/main/MainMenu.kt @@ -15,23 +15,30 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card +import androidx.compose.material.ContentAlpha +import androidx.compose.material.LocalContentColor import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Book +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Explore import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Store import androidx.compose.material.icons.outlined.Book +import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Store import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,9 +49,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ca.gosyer.BuildConfig import ca.gosyer.data.ui.model.StartScreen +import ca.gosyer.ui.base.components.combinedMouseClickable import ca.gosyer.ui.base.vm.viewModel +import ca.gosyer.ui.downloads.DownloadsMenu +import ca.gosyer.ui.downloads.DownloadsMenuViewModel import ca.gosyer.ui.extensions.ExtensionsMenu +import ca.gosyer.ui.extensions.openExtensionsMenu import ca.gosyer.ui.library.LibraryScreen +import ca.gosyer.ui.library.openLibraryMenu import ca.gosyer.ui.manga.MangaMenu import ca.gosyer.ui.settings.SettingsAdvancedScreen import ca.gosyer.ui.settings.SettingsAppearance @@ -56,6 +68,8 @@ import ca.gosyer.ui.settings.SettingsReaderScreen import ca.gosyer.ui.settings.SettingsScreen import ca.gosyer.ui.settings.SettingsServerScreen import ca.gosyer.ui.sources.SourcesMenu +import ca.gosyer.ui.sources.openSourcesMenu +import com.github.zsoltk.compose.router.BackStack import com.github.zsoltk.compose.router.Router import com.github.zsoltk.compose.savedinstancestate.Bundle import com.github.zsoltk.compose.savedinstancestate.BundleScope @@ -66,51 +80,39 @@ fun MainMenu(rootBundle: Bundle) { Surface { Router("TopLevel", vm.startScreen.toRoute()) { backStack -> Row { - Surface(elevation = 2.dp) { - Column(Modifier.width(200.dp).fillMaxHeight(),) { - Box(Modifier.fillMaxWidth().height(60.dp)) { - Text( - BuildConfig.NAME, - fontSize = 30.sp, - modifier = Modifier.align(Alignment.Center) - ) - } - Spacer(Modifier.height(20.dp)) - remember { TopLevelMenus.values() }.forEach { topLevelMenu -> - MainMenuItem( - topLevelMenu, - backStack.elements.first() == topLevelMenu.menu - ) { - backStack.newRoot(it) - } - } - } - } - Column(Modifier.fillMaxSize()) { - BundleScope("K${backStack.lastIndex}", rootBundle, false) { - when (val routing = backStack.last()) { - is Route.Library -> LibraryScreen { - backStack.push(Route.Manga(it)) - } - is Route.Sources -> SourcesMenu { - backStack.push(Route.Manga(it)) - } - is Route.Extensions -> ExtensionsMenu() - is Route.Manga -> MangaMenu(routing.mangaId, backStack) + SideMenu(backStack) + MainWindow(rootBundle, backStack) + } + } + } +} - is Route.Settings -> SettingsScreen(backStack) - is Route.SettingsGeneral -> SettingsGeneralScreen(backStack) - is Route.SettingsAppearance -> SettingsAppearance(backStack) - is Route.SettingsServer -> SettingsServerScreen(backStack) - is Route.SettingsLibrary -> SettingsLibraryScreen(backStack) - is Route.SettingsReader -> SettingsReaderScreen(backStack) - /*is Route.SettingsDownloads -> SettingsDownloadsScreen(backStack) - is Route.SettingsTracking -> SettingsTrackingScreen(backStack)*/ - is Route.SettingsBrowse -> SettingsBrowseScreen(backStack) - is Route.SettingsBackup -> SettingsBackupScreen(backStack) - /*is Route.SettingsSecurity -> SettingsSecurityScreen(backStack) - is Route.SettingsParentalControls -> SettingsParentalControlsScreen(backStack)*/ - is Route.SettingsAdvanced -> SettingsAdvancedScreen(backStack) +@Composable +fun SideMenu(backStack: BackStack) { + Surface(Modifier.width(200.dp).fillMaxHeight(), elevation = 2.dp) { + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxWidth().height(60.dp)) { + Text( + BuildConfig.NAME, + fontSize = 30.sp, + modifier = Modifier.align(Alignment.Center) + ) + } + Spacer(Modifier.height(20.dp)) + remember { TopLevelMenus.values().filter { it.top } }.forEach { topLevelMenu -> + SideMenuItem( + topLevelMenu, + backStack + ) + } + Box(Modifier.fillMaxSize()) { + Column(Modifier.align(Alignment.BottomStart).padding(bottom = 8.dp)) { + remember { TopLevelMenus.values().filterNot { it.top } }.forEach { topLevelMenu -> + SideMenuItem( + topLevelMenu, + backStack + ) } } } @@ -120,33 +122,101 @@ fun MainMenu(rootBundle: Bundle) { } @Composable -fun MainMenuItem(menu: TopLevelMenus, selected: Boolean, onClick: (Route) -> Unit) { +fun SideMenuItem(topLevelMenu: TopLevelMenus, backStack: BackStack) { + MainMenuItem( + backStack.elements.first() == topLevelMenu.menu, + topLevelMenu.text, + topLevelMenu.menu, + topLevelMenu.selectedIcon, + topLevelMenu.unselectedIcon, + topLevelMenu.openInNewWindow, + topLevelMenu.extraInfo + ) { + backStack.newRoot(it) + } +} + +@Composable +fun MainWindow(rootBundle: Bundle, backStack: BackStack) { + Column(Modifier.fillMaxSize()) { + BundleScope("K${backStack.lastIndex}", rootBundle, false) { + when (val routing = backStack.last()) { + is Route.Library -> LibraryScreen { + backStack.push(Route.Manga(it)) + } + is Route.Sources -> SourcesMenu { + backStack.push(Route.Manga(it)) + } + is Route.Extensions -> ExtensionsMenu() + is Route.Manga -> MangaMenu(routing.mangaId, backStack) + is Route.Downloads -> DownloadsMenu() + + is Route.Settings -> SettingsScreen(backStack) + is Route.SettingsGeneral -> SettingsGeneralScreen(backStack) + is Route.SettingsAppearance -> SettingsAppearance(backStack) + is Route.SettingsServer -> SettingsServerScreen(backStack) + is Route.SettingsLibrary -> SettingsLibraryScreen(backStack) + is Route.SettingsReader -> SettingsReaderScreen(backStack) + /*is Route.SettingsDownloads -> SettingsDownloadsScreen(backStack) + is Route.SettingsTracking -> SettingsTrackingScreen(backStack)*/ + is Route.SettingsBrowse -> SettingsBrowseScreen(backStack) + is Route.SettingsBackup -> SettingsBackupScreen(backStack) + /*is Route.SettingsSecurity -> SettingsSecurityScreen(backStack) + is Route.SettingsParentalControls -> SettingsParentalControlsScreen(backStack)*/ + is Route.SettingsAdvanced -> SettingsAdvancedScreen(backStack) + } + } + } +} + +@Composable +fun MainMenuItem( + selected: Boolean, + text: String, + menu: Route, + selectedIcon: ImageVector, + unselectedIcon: ImageVector, + onMiddleClick: () -> Unit, + extraInfo: (@Composable () -> Unit)? = null, + onClick: (Route) -> Unit +) { Card( - { onClick(menu.menu) }, - Modifier.fillMaxWidth().height(40.dp), + Modifier.fillMaxWidth(), backgroundColor = if (!selected) { Color.Transparent } else { MaterialTheme.colors.primary.copy(0.30F) }, - contentColor = Color.Transparent, elevation = 0.dp, shape = RoundedCornerShape(8.dp) ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + .height(40.dp) + .combinedMouseClickable( + onClick = { onClick(menu) }, + onMiddleClick = { onMiddleClick() } + ) + ) { Spacer(Modifier.width(16.dp)) Image( if (selected) { - menu.selectedIcon + selectedIcon } else { - menu.unselectedIcon + unselectedIcon }, - menu.text, - modifier = Modifier.size(20.dp), + text, + Modifier.size(20.dp), colorFilter = ColorFilter.tint(MaterialTheme.colors.onSurface) ) Spacer(Modifier.width(16.dp)) - Text(menu.text, color = MaterialTheme.colors.onSurface) + Column { + Text(text, color = MaterialTheme.colors.onSurface) + if (extraInfo != null) { + extraInfo() + } + } } } } @@ -157,11 +227,33 @@ fun StartScreen.toRoute() = when (this) { StartScreen.Extensions -> Route.Extensions } -enum class TopLevelMenus(val text: String, val unselectedIcon: ImageVector, val selectedIcon: ImageVector, val menu: Route) { - Library("Library", Icons.Outlined.Book, Icons.Filled.Book, Route.Library), - Sources("Sources", Icons.Outlined.Explore, Icons.Filled.Explore, Route.Sources), - Extensions("Extensions", Icons.Outlined.Store, Icons.Filled.Store, Route.Extensions), - Settings("Settings", Icons.Outlined.Settings, Icons.Filled.Settings, Route.Settings) +@Composable +fun DownloadsExtraInfo() { + val vm = viewModel() + val list by vm.downloadQueue.collectAsState() + if (list.isNotEmpty()) { + Text( + "${list.size} remaining", + style = MaterialTheme.typography.body2, + color = LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + ) + } +} + +enum class TopLevelMenus( + val text: String, + val unselectedIcon: ImageVector, + val selectedIcon: ImageVector, + val menu: Route, + val top: Boolean, + val openInNewWindow: () -> Unit = {}, + val extraInfo: (@Composable () -> Unit)? = null +) { + Library("Library", Icons.Outlined.Book, Icons.Filled.Book, Route.Library, true, ::openLibraryMenu), + Sources("Sources", Icons.Outlined.Explore, Icons.Filled.Explore, Route.Sources, true, ::openSourcesMenu), + Extensions("Extensions", Icons.Outlined.Store, Icons.Filled.Store, Route.Extensions, true, ::openExtensionsMenu), + Downloads("Downloads", Icons.Outlined.Download, Icons.Filled.Download, Route.Downloads, false, extraInfo = { DownloadsExtraInfo() }), + Settings("Settings", Icons.Outlined.Settings, Icons.Filled.Settings, Route.Settings, false) } sealed class Route { @@ -169,6 +261,7 @@ sealed class Route { object Sources : Route() object Extensions : Route() data class Manga(val mangaId: Long) : Route() + object Downloads : Route() object Settings : Route() object SettingsGeneral : Route() diff --git a/src/main/kotlin/ca/gosyer/ui/manga/ChapterItem.kt b/src/main/kotlin/ca/gosyer/ui/manga/ChapterItem.kt index 27cf4396..dbc36508 100644 --- a/src/main/kotlin/ca/gosyer/ui/manga/ChapterItem.kt +++ b/src/main/kotlin/ca/gosyer/ui/manga/ChapterItem.kt @@ -7,26 +7,19 @@ package ca.gosyer.ui.manga import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.forEachGesture -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ContentAlpha -import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -38,26 +31,18 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Error -import androidx.compose.material.ripple.rememberRipple 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.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import ca.gosyer.data.download.model.DownloadChapter import ca.gosyer.data.download.model.DownloadState -import ca.gosyer.ui.base.components.awaitEventFirstDown +import ca.gosyer.ui.base.components.DropdownIconButton import ca.gosyer.ui.base.components.combinedMouseClickable import ca.gosyer.util.compose.contextMenu import java.time.Instant @@ -140,7 +125,7 @@ fun ChapterItem( when (downloadState) { MangaMenuViewModel.DownloadState.Downloaded -> { - DownloadedIconButton(onClick = { deleteDownload(chapter.index) }) + DownloadedIconButton(chapter.mangaId to chapter.index, onClick = { deleteDownload(chapter.index) }) } MangaMenuViewModel.DownloadState.Downloading -> { DownloadingIconButton(downloadChapter, onClick = { stopDownload(chapter.index) }) @@ -167,7 +152,7 @@ private fun DownloadIconButton(onClick: () -> Unit) { Icons.Default.ArrowDownward, null, Modifier - .size(22.dp) + .requiredSize(22.dp) .padding(2.dp), LocalContentColor.current.copy(alpha = ContentAlpha.disabled) ) @@ -178,6 +163,7 @@ private fun DownloadIconButton(onClick: () -> Unit) { @Composable private fun DownloadingIconButton(downloadChapter: DownloadChapter?, onClick: () -> Unit) { DropdownIconButton( + downloadChapter?.mangaId to downloadChapter?.chapterIndex, { DropdownMenuItem(onClick = onClick) { Text("Cancel") @@ -187,7 +173,7 @@ private fun DownloadingIconButton(downloadChapter: DownloadChapter?, onClick: () when (downloadChapter?.state) { null, DownloadState.Queued -> CircularProgressIndicator( Modifier - .size(26.dp) + .requiredSize(26.dp) .padding(2.dp), LocalContentColor.current.copy(alpha = ContentAlpha.disabled), 2.dp @@ -196,7 +182,7 @@ private fun DownloadingIconButton(downloadChapter: DownloadChapter?, onClick: () CircularProgressIndicator( downloadChapter.progress, Modifier - .size(26.dp) + .requiredSize(26.dp) .padding(2.dp), LocalContentColor.current.copy(alpha = ContentAlpha.disabled), 2.dp @@ -205,14 +191,14 @@ private fun DownloadingIconButton(downloadChapter: DownloadChapter?, onClick: () Icons.Default.ArrowDownward, null, Modifier - .size(22.dp) + .requiredSize(22.dp) .padding(2.dp), LocalContentColor.current.copy(alpha = ContentAlpha.disabled) ) } else { CircularProgressIndicator( Modifier - .size(26.dp) + .requiredSize(26.dp) .padding(2.dp), LocalContentColor.current.copy(alpha = ContentAlpha.disabled), 2.dp @@ -223,7 +209,7 @@ private fun DownloadingIconButton(downloadChapter: DownloadChapter?, onClick: () Icons.Default.Error, null, Modifier - .size(22.dp) + .requiredSize(22.dp) .padding(2.dp), Color.Red ) @@ -233,7 +219,7 @@ private fun DownloadingIconButton(downloadChapter: DownloadChapter?, onClick: () Icons.Default.Check, null, Modifier - .size(22.dp) + .requiredSize(22.dp) .padding(2.dp), MaterialTheme.colors.surface ) @@ -243,8 +229,9 @@ private fun DownloadingIconButton(downloadChapter: DownloadChapter?, onClick: () } @Composable -private fun DownloadedIconButton(onClick: () -> Unit) { +private fun DownloadedIconButton(chapter: Pair, onClick: () -> Unit) { DropdownIconButton( + chapter, { DropdownMenuItem(onClick = onClick) { Text("Delete") @@ -256,47 +243,10 @@ private fun DownloadedIconButton(onClick: () -> Unit) { Icons.Default.Check, null, Modifier - .size(22.dp) + .requiredSize(22.dp) .padding(2.dp), MaterialTheme.colors.surface ) } } } - -@Composable -fun DropdownIconButton( - dropdownItems: @Composable ColumnScope.() -> Unit, - content: @Composable BoxScope.() -> Unit -) { - var showMenu by remember { mutableStateOf(false) } - var offset by remember { mutableStateOf(DpOffset(0.dp, 0.dp)) } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - offset = offset, - content = dropdownItems - ) - Box( - modifier = Modifier.fillMaxHeight() - .size(48.dp) - .clickable( - remember { MutableInteractionSource() }, - role = Role.Button, - indication = rememberRipple(bounded = false, radius = 24.dp) - ) { - showMenu = true - } - .pointerInput(Unit) { - forEachGesture { - awaitPointerEventScope { - awaitEventFirstDown().mouseEvent?.let { - offset = DpOffset(it.x.dp, it.y.dp) - } - } - } - }, - contentAlignment = Alignment.Center, - content = content - ) -} diff --git a/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt b/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt index 13816923..0a65bd20 100644 --- a/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt @@ -22,11 +22,13 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.unit.dp +import ca.gosyer.BuildConfig import ca.gosyer.data.models.Source import ca.gosyer.ui.base.components.KtorImage import ca.gosyer.ui.base.components.Toolbar @@ -41,9 +43,13 @@ import com.github.zsoltk.compose.savedinstancestate.BundleScope import com.github.zsoltk.compose.savedinstancestate.LocalSavedInstanceState fun openSourcesMenu() { - ThemedWindow(title = "TachideskJUI - Sources") { - SourcesMenu { - openMangaMenu(it) + ThemedWindow(BuildConfig.NAME) { + CompositionLocalProvider( + LocalSavedInstanceState provides Bundle() + ) { + SourcesMenu { + openMangaMenu(it) + } } } }