diff --git a/i18n/src/commonMain/resources/MR/values/base/strings.xml b/i18n/src/commonMain/resources/MR/values/base/strings.xml index 3eed4ca0..7b94ba1b 100644 --- a/i18n/src/commonMain/resources/MR/values/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/values/base/strings.xml @@ -39,6 +39,8 @@ Refresh Retry Close + Search + Search… Library diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/navigation/ActionMenu.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/navigation/ActionMenu.kt new file mode 100644 index 00000000..d0d31c3d --- /dev/null +++ b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/navigation/ActionMenu.kt @@ -0,0 +1,153 @@ +/* + * 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.navigation + +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.util.fastForEach + +// Originally from https://gist.github.com/MachFour/369ebb56a66e2f583ebfb988dda2decf + +// Essentially a wrapper around a lambda function to give it a name and icon +// akin to Android menu XML entries. +// 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. +@Stable +data class ActionItem( + val name: String, + val icon: ImageVector? = null, + val overflowMode: OverflowMode = OverflowMode.IF_NECESSARY, + val enabled: Boolean = true, + val doAction: () -> Unit, +) { + // allow 'calling' the action like a function + operator fun invoke() = doAction() +} + +// Whether action items are allowed to overflow into a dropdown menu - or NOT SHOWN to hide +enum class OverflowMode { + NEVER_OVERFLOW, IF_NECESSARY, ALWAYS_OVERFLOW, NOT_SHOWN +} + +// Note: should be used in a RowScope +@Composable +fun ActionMenu( + items: List, + numIcons: Int = 3, // includes overflow menu icon; may be overridden by NEVER_OVERFLOW + menuVisible: MutableState = remember { mutableStateOf(false) }, + iconItem: @Composable (onClick: () -> Unit, name: String, icon: ImageVector, enabled: Boolean) -> Unit +) { + if (items.isEmpty()) { + return + } + // decide how many action items to show as icons + val (appbarActions, overflowActions) = derivedStateOf { + separateIntoIconAndOverflow(items, numIcons) + }.value + + appbarActions.fastForEach { item -> + key(item.hashCode()) { + if (item.icon != null) { + iconItem(item.doAction, item.name, item.icon, item.enabled) + } else { + TextButton(onClick = item.doAction, enabled = item.enabled) { + Text( + text = item.name, + color = MaterialTheme.colors.onPrimary.copy(alpha = LocalContentAlpha.current), + ) + } + } + } + } + + if (overflowActions.isNotEmpty()) { + IconButton(onClick = { menuVisible.value = true }) { + Icon(Icons.Default.MoreVert, "More actions") + } + DropdownMenu( + expanded = menuVisible.value, + onDismissRequest = { menuVisible.value = false }, + ) { + overflowActions.fastForEach { item -> + key(item.hashCode()) { + DropdownMenuItem( + onClick = { + menuVisible.value = false + item() + }, + enabled = item.enabled + ) { + //Icon(item.icon, item.name) just have text in the overflow menu + Text(item.name) + } + } + } + } + } +} + +private fun separateIntoIconAndOverflow( + items: List, + numIcons: Int +): Pair, List> { + var (iconCount, overflowCount, preferIconCount) = Triple(0, 0, 0) + for (item in items) { + when (item.overflowMode) { + OverflowMode.NEVER_OVERFLOW -> iconCount++ + OverflowMode.IF_NECESSARY -> preferIconCount++ + OverflowMode.ALWAYS_OVERFLOW -> overflowCount++ + OverflowMode.NOT_SHOWN -> {} + } + } + + val needsOverflow = iconCount + preferIconCount > numIcons || overflowCount > 0 + val actionIconSpace = numIcons - (if (needsOverflow) 1 else 0) + + val iconActions = ArrayList() + val overflowActions = ArrayList() + + var iconsAvailableBeforeOverflow = actionIconSpace - iconCount + for (item in items) { + when (item.overflowMode) { + OverflowMode.NEVER_OVERFLOW -> { + iconActions.add(item) + } + OverflowMode.ALWAYS_OVERFLOW -> { + overflowActions.add(item) + } + OverflowMode.IF_NECESSARY -> { + if (iconsAvailableBeforeOverflow > 0) { + iconActions.add(item) + iconsAvailableBeforeOverflow-- + } else { + overflowActions.add(item) + } + } + OverflowMode.NOT_SHOWN -> { + // skip + } + } + } + return iconActions to overflowActions +} \ No newline at end of file diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/navigation/Toolbar.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/navigation/Toolbar.kt index 96daadb9..7ea70c16 100644 --- a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/navigation/Toolbar.kt +++ b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/base/navigation/Toolbar.kt @@ -14,9 +14,10 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -26,34 +27,39 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.AppBarDefaults import androidx.compose.material.Card import androidx.compose.material.ContentAlpha import androidx.compose.material.Icon import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentAlpha import androidx.compose.material.LocalContentColor +import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideTextStyle import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.contentColorFor import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Sort import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.graphics.RectangleShape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction @@ -64,6 +70,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ca.gosyer.i18n.MR import ca.gosyer.uicore.components.BoxWithTooltipSurface +import ca.gosyer.uicore.components.keyboardHandler import ca.gosyer.uicore.resources.stringResource import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.Navigator @@ -75,13 +82,60 @@ fun Toolbar( closable: Boolean = (navigator?.size ?: 0) > 1, onClose: () -> Unit = { navigator?.pop() }, modifier: Modifier = Modifier, - actions: @Composable RowScope.() -> Unit = {}, + actions: @Composable () -> List = { emptyList() }, backgroundColor: Color = MaterialTheme.colors.surface, // CustomColors.current.bars, contentColor: Color = contentColorFor(backgroundColor), // CustomColors.current.onBars, - elevation: Dp = AppBarDefaults.TopAppBarElevation, + elevation: Dp = Dp.Hairline, searchText: String? = null, search: ((String) -> Unit)? = null, searchSubmit: (() -> Unit)? = null, +) { + BoxWithConstraints { + if (maxWidth > 600.dp) { + WideToolbar( + name = name, + closable = closable, + onClose = onClose, + modifier = modifier, + actions = actions, + backgroundColor = backgroundColor, + contentColor = contentColor, + elevation = elevation, + searchText = searchText, + search = search, + searchSubmit = searchSubmit + ) + } else { + ThinToolbar( + name = name, + closable = closable, + onClose = onClose, + modifier = modifier, + actions = actions, + backgroundColor = backgroundColor, + contentColor = contentColor, + elevation = elevation, + searchText = searchText, + search = search, + searchSubmit = searchSubmit + ) + } + } +} + +@Composable +private fun WideToolbar( + name: String, + closable: Boolean, + onClose: () -> Unit, + modifier: Modifier, + actions: @Composable () -> List = { emptyList() }, + backgroundColor: Color, + contentColor: Color, + elevation: Dp, + searchText: String?, + search: ((String) -> Unit)?, + searchSubmit: (() -> Unit)?, ) { Surface( modifier = modifier, @@ -118,9 +172,153 @@ fun Toolbar( } Row { - actions() + ActionMenu(actions()) { onClick: () -> Unit, name: String, icon: ImageVector, enabled: Boolean -> + TextActionIcon( + onClick = onClick, + text = name, + icon = icon, + enabled = enabled + ) + } if (closable) { - TextActionIcon(onClick = onClose, stringResource(MR.strings.action_close), Icons.Rounded.Close) + TextActionIcon( + onClick = onClose, + text = stringResource(MR.strings.action_close), + icon = Icons.Rounded.Close + ) + } + } + } + } +} + + + +@Composable +private fun ThinToolbar( + name: String, + closable: Boolean, + onClose: () -> Unit, + modifier: Modifier, + actions: @Composable () -> List = { emptyList() }, + backgroundColor: Color, + contentColor: Color, + elevation: Dp, + searchText: String?, + search: ((String) -> Unit)?, + searchSubmit: (() -> Unit)?, +) { + var searchMode by remember { mutableStateOf(!searchText.isNullOrEmpty()) } + Surface( + color = backgroundColor, + contentColor = contentColor, + elevation = elevation, + shape = RectangleShape, + modifier = modifier + ) { + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + Row( + Modifier.fillMaxWidth() + .padding(AppBarDefaults.ContentPadding) + .height(56.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + if (!closable && !searchMode) { + Spacer(Modifier.width(12.dp)) + } else { + Row(Modifier.width(68.dp), verticalAlignment = Alignment.CenterVertically) { + CompositionLocalProvider( + LocalContentAlpha provides ContentAlpha.high, + ) { + IconButton( + onClick = { + if (searchMode) { + search?.invoke("") + searchSubmit?.invoke() + searchMode = false + } else { + onClose() + } + } + ) { + Icon( + Icons.Rounded.ArrowBack, + stringResource(MR.strings.action_close) + ) + } + } + } + } + + if (searchMode) { + Row( + Modifier.fillMaxHeight().weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + BasicTextField( + value = searchText.orEmpty(), + onValueChange = search ?: {}, + modifier = Modifier.fillMaxWidth() + .keyboardHandler(singleLine = true) { + searchSubmit?.invoke() + it.clearFocus() + }, + textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium)), + cursorBrush = SolidColor(MaterialTheme.colors.primary), + keyboardActions = KeyboardActions { searchSubmit?.invoke() }, + maxLines = 1, + singleLine = true, + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (searchText.isNullOrEmpty()) { + Text(stringResource(MR.strings.action_searching)) + } + innerTextField() + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search) + ) + } + } else { + Row( + Modifier.fillMaxHeight().weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + ProvideTextStyle(value = MaterialTheme.typography.h6) { + CompositionLocalProvider( + LocalContentAlpha provides ContentAlpha.high, + ) { + Text(name) + } + } + } + } + + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { + Row( + Modifier.fillMaxHeight(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + if (search != null && !searchMode) { + IconButton(onClick = { searchMode = true }) { + Icon(Icons.Rounded.Search, stringResource(MR.strings.action_search)) + } + } + ActionMenu( + actions(), + if (searchMode) { + 1 + } else { + 3 + } + ) { onClick: () -> Unit, name: String, icon: ImageVector, enabled: Boolean -> + IconButton(onClick = onClick, enabled = enabled) { + Icon(icon, name) + } + } + } } } } @@ -147,17 +345,11 @@ private fun SearchBox( searchText.orEmpty(), onValueChange = { search?.invoke(it) }, singleLine = true, - modifier = Modifier.fillMaxWidth().then( - if (searchSubmit != null) { - Modifier.onPreviewKeyEvent { event -> - (event.key == Key.Enter && event.type == KeyEventType.KeyDown).also { - if (it) { - searchSubmit() - } - } - } - } else Modifier - ), + modifier = Modifier.fillMaxWidth() + .keyboardHandler(singleLine = true) { + searchSubmit?.invoke() + it.clearFocus() + }, textStyle = TextStyle(contentColor, 18.sp), cursorBrush = SolidColor(contentColor.copy(alpha = 0.50F)), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search) diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/downloads/components/DownloadsScreenContent.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/downloads/components/DownloadsScreenContent.kt index ec38cdbf..8286c982 100644 --- a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/downloads/components/DownloadsScreenContent.kt +++ b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/downloads/components/DownloadsScreenContent.kt @@ -35,6 +35,7 @@ import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Pause import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -46,7 +47,7 @@ import ca.gosyer.data.download.model.DownloadChapter import ca.gosyer.data.download.model.DownloaderStatus import ca.gosyer.data.models.Chapter import ca.gosyer.i18n.MR -import ca.gosyer.ui.base.navigation.ActionIcon +import ca.gosyer.ui.base.navigation.ActionItem import ca.gosyer.ui.base.navigation.Toolbar import ca.gosyer.uicore.components.DropdownIconButton import ca.gosyer.uicore.components.MangaListItem @@ -74,12 +75,12 @@ fun DownloadsScreenContent( Toolbar( stringResource(MR.strings.location_downloads), actions = { - if (downloadStatus == DownloaderStatus.Started) { - ActionIcon(onClick = pauseDownloading, stringResource(MR.strings.action_pause), Icons.Rounded.Pause) - } else { - ActionIcon(onClick = startDownloading, stringResource(MR.strings.action_continue), Icons.Rounded.PlayArrow) - } - ActionIcon(onClick = clearQueue, stringResource(MR.strings.action_clear_queue), Icons.Rounded.ClearAll) + getActionItems( + downloadStatus = downloadStatus, + startDownloading = startDownloading, + pauseDownloading = pauseDownloading, + clearQueue = clearQueue + ) } ) } @@ -174,3 +175,29 @@ fun DownloadsItem( Spacer(Modifier.width(16.dp)) } } + +@Stable +@Composable +private fun getActionItems( + downloadStatus: DownloaderStatus, + startDownloading: () -> Unit, + pauseDownloading: () -> Unit, + clearQueue: () -> Unit, +) : List { + return listOf( + if (downloadStatus == DownloaderStatus.Started) { + ActionItem( + stringResource(MR.strings.action_pause), + Icons.Rounded.Pause, + doAction = pauseDownloading + ) + } else { + ActionItem( + stringResource(MR.strings.action_continue), + Icons.Rounded.PlayArrow, + doAction = startDownloading + ) + }, + ActionItem(stringResource(MR.strings.action_clear_queue), Icons.Rounded.ClearAll, doAction = clearQueue) + ) +} \ No newline at end of file diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/extensions/components/ExtensionsScreenContent.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/extensions/components/ExtensionsScreenContent.kt index 80de831b..c1b58759 100644 --- a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/extensions/components/ExtensionsScreenContent.kt +++ b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/extensions/components/ExtensionsScreenContent.kt @@ -32,6 +32,7 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Translate import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -48,7 +49,7 @@ import ca.gosyer.data.models.Extension import ca.gosyer.i18n.MR import ca.gosyer.presentation.build.BuildKonfig import ca.gosyer.ui.base.WindowDialog -import ca.gosyer.ui.base.navigation.TextActionIcon +import ca.gosyer.ui.base.navigation.ActionItem import ca.gosyer.ui.base.navigation.Toolbar import ca.gosyer.uicore.components.LoadingScreen import ca.gosyer.uicore.image.KamelImage @@ -132,15 +133,10 @@ fun ExtensionsToolbar( searchText = searchText, search = search, actions = { - TextActionIcon( - { - val enabledLangs = MutableStateFlow(currentEnabledLangs.value) - LanguageDialog(enabledLangs, getSourceLanguages().toList()) { - setEnabledLanguages(enabledLangs.value) - } - }, - stringResource(MR.strings.enabled_languages), - Icons.Rounded.Translate + getActionItems( + currentEnabledLangs = currentEnabledLangs, + getSourceLanguages = getSourceLanguages, + setEnabledLanguages = setEnabledLanguages ) } ) @@ -235,3 +231,23 @@ fun LanguageDialog(enabledLangsFlow: MutableStateFlow>, availableLan } } } + +@Stable +@Composable +private fun getActionItems( + currentEnabledLangs: StateFlow>, + getSourceLanguages: () -> Set, + setEnabledLanguages: (Set) -> Unit +): List { + return listOf( + ActionItem( + stringResource(MR.strings.enabled_languages), + Icons.Rounded.Translate + ) { + val enabledLangs = MutableStateFlow(currentEnabledLangs.value) + LanguageDialog(enabledLangs, getSourceLanguages().toList()) { + setEnabledLanguages(enabledLangs.value) + } + } + ) +} \ No newline at end of file diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/manga/components/MangaScreenContent.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/manga/components/MangaScreenContent.kt index c9fc0358..1747f6f6 100644 --- a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/manga/components/MangaScreenContent.kt +++ b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/manga/components/MangaScreenContent.kt @@ -6,7 +6,6 @@ package ca.gosyer.ui.manga.components -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight @@ -25,6 +24,7 @@ import androidx.compose.material.icons.rounded.Label import androidx.compose.material.icons.rounded.Refresh import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -32,7 +32,7 @@ import ca.gosyer.data.models.Category import ca.gosyer.data.models.Manga import ca.gosyer.i18n.MR import ca.gosyer.ui.base.chapter.ChapterDownloadItem -import ca.gosyer.ui.base.navigation.TextActionIcon +import ca.gosyer.ui.base.navigation.ActionItem import ca.gosyer.ui.base.navigation.Toolbar import ca.gosyer.ui.reader.openReaderMenu import ca.gosyer.uicore.components.ErrorScreen @@ -73,28 +73,14 @@ fun MangaScreenContent( Toolbar( stringResource(MR.strings.location_manga), actions = { - AnimatedVisibility(categoriesExist && manga?.inLibrary == true) { - TextActionIcon( - setCategories, - stringResource(MR.strings.edit_categories), - Icons.Rounded.Label - ) - } - TextActionIcon( - toggleFavorite, - stringResource(if (manga?.inLibrary == true) MR.strings.action_remove_favorite else MR.strings.action_favorite), - if (manga?.inLibrary == true) { - Icons.Rounded.Favorite - } else { - Icons.Rounded.FavoriteBorder - }, - manga != null - ) - TextActionIcon( - refreshManga, - stringResource(MR.strings.action_refresh_manga), - Icons.Rounded.Refresh, - !isLoading + getActionItems( + refreshManga = refreshManga, + refreshMangaEnabled = !isLoading, + categoryItemVisible = categoriesExist && manga?.inLibrary == true, + setCategories = setCategories, + inLibrary = manga?.inLibrary == true, + toggleFavorite = toggleFavorite, + favoritesButtonEnabled = manga != null ) } ) @@ -150,3 +136,41 @@ fun MangaScreenContent( } } } + +@Composable +@Stable +private fun getActionItems( + refreshManga: () -> Unit, + refreshMangaEnabled: Boolean, + categoryItemVisible: Boolean, + setCategories: () -> Unit, + inLibrary: Boolean, + toggleFavorite: () -> Unit, + favoritesButtonEnabled: Boolean +): List { + return listOfNotNull( + ActionItem( + name = stringResource(MR.strings.action_refresh_manga), + icon = Icons.Rounded.Refresh, + doAction = refreshManga, + enabled = refreshMangaEnabled + ), + if (categoryItemVisible) { + ActionItem( + name = stringResource(MR.strings.edit_categories), + icon = Icons.Rounded.Label, + doAction = setCategories + ) + } else null, + ActionItem( + name = stringResource(if (inLibrary) MR.strings.action_remove_favorite else MR.strings.action_favorite), + icon = if (inLibrary) { + Icons.Rounded.Favorite + } else { + Icons.Rounded.FavoriteBorder + }, + doAction = toggleFavorite, + enabled = favoritesButtonEnabled + ) + ) +} \ No newline at end of file diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/sources/browse/components/SourceScreenContent.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/sources/browse/components/SourceScreenContent.kt index 75d83260..dc7c95c7 100644 --- a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/sources/browse/components/SourceScreenContent.kt +++ b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/sources/browse/components/SourceScreenContent.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.rounded.NewReleases import androidx.compose.material.icons.rounded.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.FilterQuality @@ -34,7 +35,7 @@ import androidx.compose.ui.unit.dp import ca.gosyer.data.models.Manga import ca.gosyer.data.models.Source import ca.gosyer.i18n.MR -import ca.gosyer.ui.base.navigation.TextActionIcon +import ca.gosyer.ui.base.navigation.ActionItem import ca.gosyer.ui.base.navigation.Toolbar import ca.gosyer.ui.sources.browse.filter.SourceFiltersMenu import ca.gosyer.ui.sources.browse.filter.model.SourceFiltersView @@ -149,44 +150,21 @@ fun SourceToolbar( search = onSearch, searchSubmit = onSubmitSearch, actions = { - if (source.isConfigurable) { - TextActionIcon( - { - onSourceSettingsClick(source.id) - }, - stringResource(MR.strings.location_settings), - Icons.Rounded.Settings - ) - } - if (showFilterButton) { - TextActionIcon( - { - onToggleFiltersClick(!showingFilters) - }, - stringResource(MR.strings.filter_source), - Icons.Rounded.FilterList, - !isLatest - ) - } - if (showLatestButton) { - TextActionIcon( - { - onClickMode(!isLatest) - }, - stringResource( - if (isLatest) { - MR.strings.move_to_browse - } else { - MR.strings.move_to_latest - } - ), - if (isLatest) { - Icons.Rounded.Explore - } else { - Icons.Rounded.NewReleases - } - ) - } + getActionItems( + isConfigurable = source.isConfigurable, + onSourceSettingsClick = { + onSourceSettingsClick(source.id) + }, + isLatest = isLatest, + showLatestButton = showLatestButton, + showFilterButton = showFilterButton, + onToggleFiltersClick = { + onToggleFiltersClick(!showingFilters) + }, + onClickMode = { + onClickMode(!isLatest) + } + ) } ) } @@ -227,3 +205,50 @@ private fun MangaTable( } } } + +@Composable +@Stable +private fun getActionItems( + isConfigurable: Boolean, + onSourceSettingsClick: () -> Unit, + isLatest: Boolean, + showLatestButton: Boolean, + showFilterButton: Boolean, + onToggleFiltersClick: () -> Unit, + onClickMode: () -> Unit +): List { + return listOfNotNull( + if (isConfigurable) { + ActionItem( + name = stringResource(MR.strings.location_settings), + icon = Icons.Rounded.Settings, + doAction = onSourceSettingsClick + ) + } else null, + if (showFilterButton) { + ActionItem( + name = stringResource(MR.strings.filter_source), + icon = Icons.Rounded.FilterList, + doAction = onToggleFiltersClick, + enabled = !isLatest + ) + } else null, + if (showLatestButton) { + ActionItem( + name = stringResource( + if (isLatest) { + MR.strings.move_to_browse + } else { + MR.strings.move_to_latest + } + ), + icon = if (isLatest) { + Icons.Rounded.Explore + } else { + Icons.Rounded.NewReleases + }, + doAction = onClickMode + ) + } else null + ) +} \ No newline at end of file diff --git a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/sources/home/components/SourceHomeScreenContent.kt b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/sources/home/components/SourceHomeScreenContent.kt index 88ff6e49..932eb1c9 100644 --- a/presentation/src/desktopMain/kotlin/ca/gosyer/ui/sources/home/components/SourceHomeScreenContent.kt +++ b/presentation/src/desktopMain/kotlin/ca/gosyer/ui/sources/home/components/SourceHomeScreenContent.kt @@ -33,6 +33,7 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Translate import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow @@ -41,7 +42,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import ca.gosyer.data.models.Source import ca.gosyer.i18n.MR -import ca.gosyer.ui.base.navigation.TextActionIcon +import ca.gosyer.ui.base.navigation.ActionItem import ca.gosyer.ui.base.navigation.Toolbar import ca.gosyer.ui.extensions.components.LanguageDialog import ca.gosyer.uicore.components.LoadingScreen @@ -109,15 +110,13 @@ fun SourceHomeScreenToolbar( Toolbar( stringResource(MR.strings.location_sources), actions = { - TextActionIcon( - { + getActionItems( + onEnabledLanguagesClick = { val enabledLangs = MutableStateFlow(sourceLanguages.value) LanguageDialog(enabledLangs, onGetEnabledLanguages().toList()) { onSetEnabledLanguages(enabledLangs.value) } - }, - stringResource(MR.strings.enabled_languages), - Icons.Rounded.Translate + } ) } ) @@ -176,3 +175,17 @@ fun SourceItem( } } } + +@Composable +@Stable +private fun getActionItems( + onEnabledLanguagesClick: () -> Unit +): List { + return listOf( + ActionItem( + stringResource(MR.strings.enabled_languages), + Icons.Rounded.Translate, + doAction = onEnabledLanguagesClick + ) + ) +} \ No newline at end of file diff --git a/ui-core/src/jvmMain/kotlin/ca/gosyer/uicore/components/KeyboardHandler.kt b/ui-core/src/jvmMain/kotlin/ca/gosyer/uicore/components/KeyboardHandler.kt new file mode 100644 index 00000000..9fb0be53 --- /dev/null +++ b/ui-core/src/jvmMain/kotlin/ca/gosyer/uicore/components/KeyboardHandler.kt @@ -0,0 +1,33 @@ +package ca.gosyer.uicore.components + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager + +/** + * A modifier to handle keyboard keys properly + */ +@OptIn(ExperimentalComposeUiApi::class) +fun Modifier.keyboardHandler( + singleLine: Boolean = false, + action: (FocusManager) -> Unit = { it.moveFocus(FocusDirection.Down) } +) = composed { + val focusManager = LocalFocusManager.current + Modifier.onPreviewKeyEvent { + if ( + (it.key == Key.Tab || (singleLine && it.key == Key.Enter)) && + it.type == KeyEventType.KeyDown + ) { + action(focusManager) + true + } else false + } +}