From 5487f2d532dcb070d970154eb2b3450c0dc50d2c Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Sat, 26 Aug 2023 18:58:15 +0200 Subject: [PATCH 01/10] Add full more options M3 dialog --- app/build.gradle.kts | 2 + .../jerboa/ui/components/common/Dialogs.kt | 2 + .../com/jerboa/ui/components/home/Home.kt | 52 ++++++++++++------- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 33db4ee09..3ec6ad458 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -187,4 +187,6 @@ dependencies { baselineProfile(project(":benchmarks")) implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5") + + implementation("me.saket.cascade:cascade-compose:2.2.0") } diff --git a/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt b/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt index ae7bb2e29..aaa533b1b 100644 --- a/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt +++ b/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -96,6 +97,7 @@ fun SortOptionsDialog( AlertDialog( modifier = Modifier.semantics { testTagsAsResourceId = true }, onDismissRequest = onDismissRequest, + containerColor = MaterialTheme.colorScheme.surface, text = { Column { SortType.getSupportedSortTypes(siteVersion).filter { !isTopSort(it) }.forEach { diff --git a/app/src/main/java/com/jerboa/ui/components/home/Home.kt b/app/src/main/java/com/jerboa/ui/components/home/Home.kt index 646acf743..c41aa22ae 100644 --- a/app/src/main/java/com/jerboa/ui/components/home/Home.kt +++ b/app/src/main/java/com/jerboa/ui/components/home/Home.kt @@ -1,5 +1,6 @@ package com.jerboa.ui.components.home +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -15,6 +16,7 @@ import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Sort import androidx.compose.material.icons.outlined.ViewAgenda import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -50,6 +52,7 @@ import com.jerboa.ui.components.common.SortOptionsDialog import com.jerboa.ui.components.common.SortTopOptionsDialog import com.jerboa.ui.theme.LARGE_PADDING import kotlinx.collections.immutable.ImmutableList +import me.saket.cascade.CascadeDropdownMenu @Composable fun HomeHeaderTitle( @@ -188,11 +191,9 @@ fun HomeHeader( expanded = showMoreOptions, onDismissRequest = { showMoreOptions = false }, onClickRefresh = onClickRefresh, - onClickShowPostViewModeDialog = { - showMoreOptions = false - showPostViewModeOptions = !showPostViewModeOptions - }, onClickSiteInfo = onClickSiteInfo, + selectedPostViewMode = selectedPostViewMode, + onClickPostViewMode = onClickPostViewMode, ) } }, @@ -225,34 +226,49 @@ fun HomeMoreDropdown( onDismissRequest: () -> Unit, onClickSiteInfo: () -> Unit, onClickRefresh: () -> Unit, - onClickShowPostViewModeDialog: () -> Unit, + onClickPostViewMode: (PostViewMode) -> Unit, + selectedPostViewMode: PostViewMode, ) { - DropdownMenu( + CascadeDropdownMenu( expanded = expanded, onDismissRequest = onDismissRequest, modifier = Modifier.semantics { testTagsAsResourceId = true }, ) { - MenuItem( - text = stringResource(R.string.home_refresh), - icon = Icons.Outlined.Refresh, + DropdownMenuItem( + text = { Text(text = stringResource(R.string.home_refresh)) }, + leadingIcon = { Icon(Icons.Outlined.Refresh, contentDescription = null) }, onClick = { onDismissRequest() onClickRefresh() }, modifier = Modifier.testTag("jerboa:refresh"), ) - MenuItem( - text = stringResource(R.string.home_post_view_mode), - icon = Icons.Outlined.ViewAgenda, - onClick = { - onDismissRequest() - onClickShowPostViewModeDialog() + DropdownMenuItem( + text = { Text(text = stringResource(R.string.home_post_view_mode)) }, + leadingIcon = { Icon(Icons.Outlined.ViewAgenda, contentDescription = null) }, + children = { + PostViewMode.entries.map { + DropdownMenuItem( + text = { Text(text = stringResource(it.mode)) }, + onClick = { + onClickPostViewMode(it) + onDismissRequest() + }, + + modifier = + if (selectedPostViewMode == it) { + Modifier.background(MaterialTheme.colorScheme.onBackground.copy(alpha = .1f)) + } else { + Modifier + }.testTag("jerboa:postviewmode_${it.name}"), + ) + } }, modifier = Modifier.testTag("jerboa:postviewmode"), ) - MenuItem( - text = stringResource(R.string.home_site_info), - icon = Icons.Outlined.Info, + DropdownMenuItem( + text = { Text(stringResource(R.string.home_site_info)) }, + leadingIcon = { Icon(Icons.Outlined.Info, contentDescription = null) }, onClick = { onClickSiteInfo() onDismissRequest() From 755676cb98e23553607e31450e36a548b6241e2d Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Sat, 26 Aug 2023 19:07:01 +0200 Subject: [PATCH 02/10] Add full more options M3 dialog --- app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt b/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt index aaa533b1b..6a34f8900 100644 --- a/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt +++ b/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt @@ -97,7 +97,6 @@ fun SortOptionsDialog( AlertDialog( modifier = Modifier.semantics { testTagsAsResourceId = true }, onDismissRequest = onDismissRequest, - containerColor = MaterialTheme.colorScheme.surface, text = { Column { SortType.getSupportedSortTypes(siteVersion).filter { !isTopSort(it) }.forEach { From ce3e050501db0e2965b23994ed5a27881de75904 Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Sat, 26 Aug 2023 19:10:06 +0200 Subject: [PATCH 03/10] Remove old dialog --- .../jerboa/ui/components/common/Dialogs.kt | 28 ------------------- .../com/jerboa/ui/components/home/Home.kt | 16 +---------- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt b/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt index 6a34f8900..521cd38d0 100644 --- a/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt +++ b/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt @@ -9,7 +9,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -27,7 +26,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview -import com.jerboa.PostViewMode import com.jerboa.R import com.jerboa.api.MINIMUM_API_VERSION import com.jerboa.datatypes.types.CommentSortType @@ -156,32 +154,6 @@ fun CommentSortOptionsDialog( ) } -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun PostViewModeDialog( - onDismissRequest: () -> Unit, - onClickPostViewMode: (PostViewMode) -> Unit, - selectedPostViewMode: PostViewMode, -) { - AlertDialog( - modifier = Modifier.semantics { testTagsAsResourceId = true }, - onDismissRequest = onDismissRequest, - text = { - Column { - PostViewMode.entries.map { - IconAndTextDrawerItem( - text = stringResource(it.mode), - onClick = { onClickPostViewMode(it) }, - highlight = (selectedPostViewMode == it), - modifier = Modifier.testTag("jerboa:postviewmode_${it.name}"), - ) - } - } - }, - confirmButton = {}, - ) -} - @OptIn(ExperimentalComposeUiApi::class) @Composable fun ShowChangelog(appSettingsViewModel: AppSettingsViewModel) { diff --git a/app/src/main/java/com/jerboa/ui/components/home/Home.kt b/app/src/main/java/com/jerboa/ui/components/home/Home.kt index c41aa22ae..c8f797c2e 100644 --- a/app/src/main/java/com/jerboa/ui/components/home/Home.kt +++ b/app/src/main/java/com/jerboa/ui/components/home/Home.kt @@ -47,7 +47,6 @@ import com.jerboa.datatypes.types.Tagline import com.jerboa.getLocalizedListingTypeName import com.jerboa.ui.components.common.MenuItem import com.jerboa.ui.components.common.MyMarkdownText -import com.jerboa.ui.components.common.PostViewModeDialog import com.jerboa.ui.components.common.SortOptionsDialog import com.jerboa.ui.components.common.SortTopOptionsDialog import com.jerboa.ui.theme.LARGE_PADDING @@ -91,7 +90,6 @@ fun HomeHeader( var showTopOptions by remember { mutableStateOf(false) } var showListingTypeOptions by remember { mutableStateOf(false) } var showMoreOptions by remember { mutableStateOf(false) } - var showPostViewModeOptions by remember { mutableStateOf(false) } if (showSortOptions) { SortOptionsDialog( @@ -121,16 +119,6 @@ fun HomeHeader( ) } - if (showPostViewModeOptions) { - PostViewModeDialog( - onDismissRequest = { showPostViewModeOptions = false }, - selectedPostViewMode = selectedPostViewMode, - onClickPostViewMode = { - showPostViewModeOptions = false - onClickPostViewMode(it) - }, - ) - } TopAppBar( scrollBehavior = scrollBehavior, title = { @@ -254,9 +242,7 @@ fun HomeMoreDropdown( onClickPostViewMode(it) onDismissRequest() }, - - modifier = - if (selectedPostViewMode == it) { + modifier = if (selectedPostViewMode == it) { Modifier.background(MaterialTheme.colorScheme.onBackground.copy(alpha = .1f)) } else { Modifier From cd06876a967afb16d5bd965caf730cdcf7393d59 Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Sat, 26 Aug 2023 22:54:11 +0200 Subject: [PATCH 04/10] Add dropdown to community --- .../ui/components/community/Community.kt | 85 ++++++++++--------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/jerboa/ui/components/community/Community.kt b/app/src/main/java/com/jerboa/ui/components/community/Community.kt index 970b80b6f..4eebbff20 100644 --- a/app/src/main/java/com/jerboa/ui/components/community/Community.kt +++ b/app/src/main/java/com/jerboa/ui/components/community/Community.kt @@ -1,6 +1,7 @@ package com.jerboa.ui.components.community import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons @@ -10,6 +11,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.jerboa.PostViewMode @@ -19,12 +21,11 @@ import com.jerboa.datatypes.types.CommunityView import com.jerboa.datatypes.types.SortType import com.jerboa.datatypes.types.SubscribedType import com.jerboa.ui.components.common.LargerCircularIcon -import com.jerboa.ui.components.common.MenuItem import com.jerboa.ui.components.common.PictrsBannerImage -import com.jerboa.ui.components.common.PostViewModeDialog import com.jerboa.ui.components.common.SortOptionsDialog import com.jerboa.ui.components.common.SortTopOptionsDialog import com.jerboa.ui.theme.* +import me.saket.cascade.CascadeDropdownMenu @Composable fun CommunityTopSection( @@ -142,7 +143,6 @@ fun CommunityHeader( var showSortOptions by remember { mutableStateOf(false) } var showTopOptions by remember { mutableStateOf(false) } var showMoreOptions by remember { mutableStateOf(false) } - var showPostViewModeOptions by remember { mutableStateOf(false) } if (showSortOptions) { SortOptionsDialog( @@ -172,17 +172,6 @@ fun CommunityHeader( ) } - if (showPostViewModeOptions) { - PostViewModeDialog( - onDismissRequest = { showPostViewModeOptions = false }, - selectedPostViewMode = selectedPostViewMode, - onClickPostViewMode = { - showPostViewModeOptions = false - onClickPostViewMode(it) - }, - ) - } - TopAppBar( scrollBehavior = scrollBehavior, title = { @@ -221,15 +210,13 @@ fun CommunityHeader( expanded = showMoreOptions, onDismissRequest = { showMoreOptions = false }, onClickRefresh = onClickRefresh, - onClickShowPostViewModeDialog = { - showMoreOptions = false - showPostViewModeOptions = true - }, onBlockCommunityClick = { showMoreOptions = false onBlockCommunityClick() }, onClickCommunityInfo = onClickCommunityInfo, + onClickPostViewMode = onClickPostViewMode, + selectedPostViewMode = selectedPostViewMode, isBlocked = isBlocked, ) } @@ -265,44 +252,64 @@ fun CommunityMoreDropdown( onBlockCommunityClick: () -> Unit, onClickRefresh: () -> Unit, onClickCommunityInfo: () -> Unit, - onClickShowPostViewModeDialog: () -> Unit, + onClickPostViewMode: (PostViewMode) -> Unit, + selectedPostViewMode: PostViewMode, isBlocked: Boolean, ) { - DropdownMenu( + CascadeDropdownMenu( expanded = expanded, onDismissRequest = onDismissRequest, ) { - MenuItem( - text = stringResource(R.string.community_refresh), - icon = Icons.Outlined.Refresh, + DropdownMenuItem( + text = { Text(text = stringResource(R.string.home_refresh)) }, + leadingIcon = { Icon(Icons.Outlined.Refresh, contentDescription = null) }, onClick = { onDismissRequest() onClickRefresh() }, + modifier = Modifier.testTag("jerboa:refresh"), ) - MenuItem( - text = stringResource(R.string.home_post_view_mode), - icon = Icons.Outlined.ViewAgenda, - onClick = { - onDismissRequest() - onClickShowPostViewModeDialog() + DropdownMenuItem( + text = { Text(text = stringResource(R.string.home_post_view_mode)) }, + leadingIcon = { Icon(Icons.Outlined.ViewAgenda, contentDescription = null) }, + children = { + PostViewMode.entries.map { + DropdownMenuItem( + text = { Text(text = stringResource(it.mode)) }, + onClick = { + onClickPostViewMode(it) + onDismissRequest() + }, + modifier = if (selectedPostViewMode == it) { + Modifier.background(MaterialTheme.colorScheme.onBackground.copy(alpha = .1f)) + } else { + Modifier + }.testTag("jerboa:postviewmode_${it.name}"), + ) + } }, + modifier = Modifier.testTag("jerboa:postviewmode"), ) - MenuItem( - text = stringResource(R.string.community_community_info), - icon = Icons.Outlined.Info, + DropdownMenuItem( + text = { Text(stringResource(R.string.community_community_info)) }, + leadingIcon = { Icon(Icons.Outlined.Info, contentDescription = null) }, onClick = { onClickCommunityInfo() onDismissRequest() }, ) - MenuItem( - text = stringResource( - if (isBlocked) { - R.string.community_unblock_community - } else R.string.community_block_community, - ), - icon = Icons.Outlined.Block, + Divider() + DropdownMenuItem( + text = { + Text( + stringResource( + if (isBlocked) { + R.string.community_unblock_community + } else R.string.community_block_community, + ), + ) + }, + leadingIcon = { Icon(Icons.Outlined.Block, contentDescription = null) }, onClick = onBlockCommunityClick, ) } From 298a861ef6bcfd1e1964887e4c462375e9e23972 Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Sun, 27 Aug 2023 22:53:13 +0200 Subject: [PATCH 05/10] Add custom CascadeDropdownMenu with fixes --- app/build.gradle.kts | 2 - .../ui/components/community/Community.kt | 16 +- .../com/jerboa/ui/components/home/Home.kt | 6 +- .../java/com/jerboa/util/cascade/Cascade.kt | 399 ++++++++++++++++++ .../com/jerboa/util/cascade/CascadeState.kt | 55 +++ .../util/cascade/internal/AnimateEntryExit.kt | 184 ++++++++ .../cascade/internal/CoercePositiveValues.kt | 42 ++ .../internal/DropdownMenuPositionProvider.kt | 114 +++++ .../cascade/internal/PositionPopupContent.kt | 81 ++++ .../cascade/internal/ScreenRelativeBounds.kt | 60 +++ .../cascade/internal/cascadeTransitionSpec.kt | 33 ++ .../internal/clickableWithoutRipple.kt | 19 + .../util/cascade/internal/popupProperties.kt | 19 + 13 files changed, 1017 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/jerboa/util/cascade/Cascade.kt create mode 100644 app/src/main/java/com/jerboa/util/cascade/CascadeState.kt create mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/AnimateEntryExit.kt create mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/CoercePositiveValues.kt create mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/DropdownMenuPositionProvider.kt create mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/PositionPopupContent.kt create mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/ScreenRelativeBounds.kt create mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/cascadeTransitionSpec.kt create mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/clickableWithoutRipple.kt create mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/popupProperties.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3ec6ad458..33db4ee09 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -187,6 +187,4 @@ dependencies { baselineProfile(project(":benchmarks")) implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5") - - implementation("me.saket.cascade:cascade-compose:2.2.0") } diff --git a/app/src/main/java/com/jerboa/ui/components/community/Community.kt b/app/src/main/java/com/jerboa/ui/components/community/Community.kt index 4eebbff20..a780c2204 100644 --- a/app/src/main/java/com/jerboa/ui/components/community/Community.kt +++ b/app/src/main/java/com/jerboa/ui/components/community/Community.kt @@ -25,7 +25,7 @@ import com.jerboa.ui.components.common.PictrsBannerImage import com.jerboa.ui.components.common.SortOptionsDialog import com.jerboa.ui.components.common.SortTopOptionsDialog import com.jerboa.ui.theme.* -import me.saket.cascade.CascadeDropdownMenu +import com.jerboa.util.cascade.CascadeDropdownMenu @Composable fun CommunityTopSection( @@ -210,10 +210,7 @@ fun CommunityHeader( expanded = showMoreOptions, onDismissRequest = { showMoreOptions = false }, onClickRefresh = onClickRefresh, - onBlockCommunityClick = { - showMoreOptions = false - onBlockCommunityClick() - }, + onBlockCommunityClick = onBlockCommunityClick, onClickCommunityInfo = onClickCommunityInfo, onClickPostViewMode = onClickPostViewMode, selectedPostViewMode = selectedPostViewMode, @@ -277,8 +274,8 @@ fun CommunityMoreDropdown( DropdownMenuItem( text = { Text(text = stringResource(it.mode)) }, onClick = { - onClickPostViewMode(it) onDismissRequest() + onClickPostViewMode(it) }, modifier = if (selectedPostViewMode == it) { Modifier.background(MaterialTheme.colorScheme.onBackground.copy(alpha = .1f)) @@ -294,8 +291,8 @@ fun CommunityMoreDropdown( text = { Text(stringResource(R.string.community_community_info)) }, leadingIcon = { Icon(Icons.Outlined.Info, contentDescription = null) }, onClick = { - onClickCommunityInfo() onDismissRequest() + onClickCommunityInfo() }, ) Divider() @@ -310,7 +307,10 @@ fun CommunityMoreDropdown( ) }, leadingIcon = { Icon(Icons.Outlined.Block, contentDescription = null) }, - onClick = onBlockCommunityClick, + onClick = { + onDismissRequest() + onBlockCommunityClick() + }, ) } } diff --git a/app/src/main/java/com/jerboa/ui/components/home/Home.kt b/app/src/main/java/com/jerboa/ui/components/home/Home.kt index c8f797c2e..456b76df6 100644 --- a/app/src/main/java/com/jerboa/ui/components/home/Home.kt +++ b/app/src/main/java/com/jerboa/ui/components/home/Home.kt @@ -50,8 +50,8 @@ import com.jerboa.ui.components.common.MyMarkdownText import com.jerboa.ui.components.common.SortOptionsDialog import com.jerboa.ui.components.common.SortTopOptionsDialog import com.jerboa.ui.theme.LARGE_PADDING +import com.jerboa.util.cascade.CascadeDropdownMenu import kotlinx.collections.immutable.ImmutableList -import me.saket.cascade.CascadeDropdownMenu @Composable fun HomeHeaderTitle( @@ -239,8 +239,8 @@ fun HomeMoreDropdown( DropdownMenuItem( text = { Text(text = stringResource(it.mode)) }, onClick = { - onClickPostViewMode(it) onDismissRequest() + onClickPostViewMode(it) }, modifier = if (selectedPostViewMode == it) { Modifier.background(MaterialTheme.colorScheme.onBackground.copy(alpha = .1f)) @@ -256,8 +256,8 @@ fun HomeMoreDropdown( text = { Text(stringResource(R.string.home_site_info)) }, leadingIcon = { Icon(Icons.Outlined.Info, contentDescription = null) }, onClick = { - onClickSiteInfo() onDismissRequest() + onClickSiteInfo() }, ) } diff --git a/app/src/main/java/com/jerboa/util/cascade/Cascade.kt b/app/src/main/java/com/jerboa/util/cascade/Cascade.kt new file mode 100644 index 000000000..ec8f74527 --- /dev/null +++ b/app/src/main/java/com/jerboa/util/cascade/Cascade.kt @@ -0,0 +1,399 @@ +@file:OptIn(ExperimentalAnimationApi::class) + +package com.jerboa.util.cascade + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.LayoutScopeMarker +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowLeft +import androidx.compose.material.icons.rounded.ArrowRight +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.MenuItemColors +import androidx.compose.material3.Surface +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +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.runtime.snapshotFlow +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.LayoutDirection.Ltr +import androidx.compose.ui.unit.LayoutDirection.Rtl +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.jerboa.util.cascade.internal.AnimateEntryExit +import com.jerboa.util.cascade.internal.CoercePositiveValues +import com.jerboa.util.cascade.internal.DropdownMenuPositionProvider +import com.jerboa.util.cascade.internal.PositionPopupContent +import com.jerboa.util.cascade.internal.ScreenRelativeBounds +import com.jerboa.util.cascade.internal.calculateTransformOrigin +import com.jerboa.util.cascade.internal.cascadeTransitionSpec +import com.jerboa.util.cascade.internal.clickableWithoutRipple +import com.jerboa.util.cascade.internal.copy +import com.jerboa.util.cascade.internal.then +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach + +// TODO: remove and depend on upstream once fixes have been merged + +/** + * Material Design dropdown menu with support for nested menus. + * See [DropdownMenu] for documentation about its parameters. + * + * Example usage: + * + * ``` + * var expanded by rememberSaveable { mutableStateOf(false) } + * + * CascadeDropdownMenu( + * expanded = expanded, + * onDismissRequest = { expanded = false } + * ) { + * DropdownMenuItem( + * text = { Text("Horizon") }, + * children = { + * DropdownMenuItem( + * text = { Text("Zero Dawn") }, + * onClick = { … } + * ) + * DropdownMenuItem( + * text = { Text("Forbidden West") }, + * onClick = { … } + * ) + * } + * ) + * } + * ``` + * + * @param fixedWidth A width that will be shared by all nested menus. This can be removed + * in the future once cascade is able to animate width changes across nested menus. + * + * @param shadowElevation A value between 0dp and 8dp. Cascade trims values above 8dp to match [DropdownMenu]'s behavior. + * [More context can be found here](https://android-review.googlesource.com/c/platform/frameworks/support/+/2117953). + */ +@Composable +fun CascadeDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + offset: DpOffset = DpOffset.Zero, + fixedWidth: Dp = 196.dp, + shadowElevation: Dp = 3.dp, + properties: PopupProperties = PopupProperties(focusable = true), + state: CascadeState = rememberCascadeState(), + content: @Composable CascadeColumnScope.() -> Unit, +) { + val expandedStates = remember { MutableTransitionState(false) } + expandedStates.targetState = expanded + + if (expandedStates.currentState || expandedStates.targetState) { + val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) } + val popupPositionProvider = CoercePositiveValues( + DropdownMenuPositionProvider( + offset, + LocalDensity.current, + ) { parentBounds, menuBounds -> + transformOriginState.value = calculateTransformOrigin( + parentBounds = parentBounds, + menuBounds = CoercePositiveValues.correctMenuBounds(menuBounds), + ) + }, + ) + + val anchorHostView = LocalView.current + var anchorBounds: ScreenRelativeBounds? by remember { mutableStateOf(null) } + Box( + Modifier.onGloballyPositioned { coordinates -> + // FYI: + // coordinates -> this box. + // coordinates.parent -> "anchor" composable that contains CascadeDropdownMenu(). + anchorBounds = ScreenRelativeBounds(coordinates.parentLayoutCoordinates!!, owner = anchorHostView) + }, + ) + + // A full sized popup is shown so that content can render fake shadows + // that do not suffer from https://issuetracker.google.com/issues/236109671. + Popup( + onDismissRequest = onDismissRequest, + properties = properties.copy(usePlatformDefaultWidth = false), + ) { + PositionPopupContent( + modifier = Modifier + .fillMaxSize() + .then(properties.dismissOnClickOutside) { + clickableWithoutRipple(onClick = onDismissRequest) + }, + positionProvider = popupPositionProvider, + anchorBounds = anchorBounds, + ) { + PopupContent( + modifier = Modifier + // Prevent clicks from leaking behind. Otherwise, they'll get picked up as outside + // clicks to dismiss the popup. This must be set _before_ the downstream modifiers to + // avoid overriding any clickable modifiers registered by the developer. + .clickableWithoutRipple {} + .then(modifier), + state = state, + fixedWidth = fixedWidth, + expandedStates = expandedStates, + transformOriginState = transformOriginState, + shadowElevation = shadowElevation, + content = content, + ) + } + } + } +} + +@Composable +internal fun PopupContent( + modifier: Modifier = Modifier, + state: CascadeState, + fixedWidth: Dp, + shadowElevation: Dp, + expandedStates: MutableTransitionState, + transformOriginState: MutableState, + content: + @Composable() + (CascadeColumnScope.() -> Unit), +) { + AnimateEntryExit( + expandedStates = expandedStates, + transformOriginState = transformOriginState, + // 8dp is the maximum recommended elevation. + // More context here: https://android-review.googlesource.com/c/platform/frameworks/support/+/2117953 + shadowElevation = shadowElevation.coerceAtMost(8.dp), + ) { + CascadeDropdownMenuContent( + modifier = Modifier + .requiredWidth(fixedWidth) + .then(modifier), + state = state, + tonalElevation = shadowElevation, + content = content, + ) + } +} + +@Composable +private fun CascadeDropdownMenuContent( + state: CascadeState, + modifier: Modifier = Modifier, + tonalElevation: Dp, + content: @Composable CascadeColumnScope.() -> Unit, +) { + DisposableEffect(Unit) { + onDispose { + state.resetBackStack() + } + } + + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + tonalElevation = tonalElevation, + ) { + val isTransitionRunning = remember { MutableStateFlow(false) } + val backStackSnapshot by remember { + snapshotFlow { state.backStackSnapshot() } + .onEach { + // Block until any ongoing transition has finished. This is a very crude + // way of queueing navigations. AnimatedContent() does not like it when + // its content is changed before it is able to finish a transition. + isTransitionRunning.first { running -> !running } + } + }.collectAsState(initial = state.backStackSnapshot()) + + val layoutDirection = LocalLayoutDirection.current + AnimatedContent( + modifier = modifier, + targetState = backStackSnapshot, + transitionSpec = { cascadeTransitionSpec(layoutDirection) }, + label = "cascadeAnimation", + ) { snapshot -> + Column( + Modifier + // Provide a solid background color to prevent the + // content of sub-menus from leaking into each other. + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) + .verticalScroll(rememberScrollState()), + ) { + val currentContent = snapshot.topMostEntry?.childrenContent ?: content + snapshot.topMostEntry?.header?.invoke() + + val contentScope = remember { CascadeColumnScope(state) } + contentScope.currentContent() + } + + LaunchedEffect(transition.isRunning) { + isTransitionRunning.tryEmit(transition.isRunning) + } + } + } +} + +@Immutable +@LayoutScopeMarker +interface CascadeColumnScope : ColumnScope { + val cascadeState: CascadeState + + /** + * Material Design dropdown menu item that navigates to a sub-menu on click. + * See [androidx.compose.material3.DropdownMenuItem] for documentation about its parameters. + * + * For sub-menus, cascade will automatically navigate to their parent menu when their + * header is clicked. For manual navigation, [CascadeState.navigateBack] can be used. + * + * ``` + * val state = rememberCascadeState() + * + * CascadeDropdownMenu(state = state, ...) { + * DropdownMenuItem( + * text = { Text("Are you sure?"), + * children = { + * DropdownMenuItem( + * text = { Text("Not really") }, + * onClick = { state.navigateBack() } + * ) + * } + * ) + * } + * ``` + */ + @Composable + fun DropdownMenuItem( + text: @Composable () -> Unit, + children: @Composable CascadeColumnScope.() -> Unit, + modifier: Modifier = Modifier, + childrenHeader: @Composable () -> Unit = { DropdownMenuHeader(text = text) }, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + enabled: Boolean = true, + colors: MenuItemColors = MenuDefaults.itemColors(), + contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + ) { + DropdownMenuItem( + text = text, + onClick = { + cascadeState.navigateTo( + CascadeBackStackEntry( + header = childrenHeader, + childrenContent = children, + ), + ) + }, + modifier = modifier, + leadingIcon = leadingIcon, + trailingIcon = { + Row(verticalAlignment = CenterVertically) { + trailingIcon?.invoke() + + val requiredGapWithEdge = 4.dp + val iconOffset = contentPadding.calculateEndPadding(LocalLayoutDirection.current) - requiredGapWithEdge + Icon( + modifier = Modifier.offset(x = iconOffset), + imageVector = when (LocalLayoutDirection.current) { + Ltr -> Icons.Rounded.ArrowRight + Rtl -> Icons.Rounded.ArrowLeft + }, + contentDescription = null, + ) + } + }, + enabled = enabled, + colors = colors, + contentPadding = contentPadding, + interactionSource = interactionSource, + ) + } + + /** + * Displays `text` with a back icon. Navigates to its parent menu when clicked. + */ + @Composable + fun DropdownMenuHeader( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(vertical = 4.dp), + text: @Composable () -> Unit, + ) { + Row( + modifier = modifier + .clickable { cascadeState.navigateBack() } + .fillMaxWidth() + .padding(contentPadding), + verticalAlignment = CenterVertically, + ) { + val headerColor = LocalContentColor.current.copy(alpha = 0.6f) + val headerStyle = MaterialTheme.typography.labelLarge.run { // labelLarge is also used by DropdownMenuItem(). + copy( + fontSize = fontSize * 0.9f, + letterSpacing = letterSpacing * 0.9f, + ) + } + CompositionLocalProvider( + LocalContentColor provides headerColor, + LocalTextStyle provides headerStyle, + ) { + Icon( + modifier = Modifier.requiredSize(32.dp), + imageVector = when (LocalLayoutDirection.current) { + Ltr -> Icons.Rounded.ArrowLeft + Rtl -> Icons.Rounded.ArrowRight + }, + contentDescription = null, + ) + Box(Modifier.weight(1f)) { + text() + } + } + } + } +} + +private fun ColumnScope.CascadeColumnScope(state: CascadeState): CascadeColumnScope = + object : CascadeColumnScope, ColumnScope by this { + override val cascadeState get() = state + } diff --git a/app/src/main/java/com/jerboa/util/cascade/CascadeState.kt b/app/src/main/java/com/jerboa/util/cascade/CascadeState.kt new file mode 100644 index 000000000..1ff601293 --- /dev/null +++ b/app/src/main/java/com/jerboa/util/cascade/CascadeState.kt @@ -0,0 +1,55 @@ +package com.jerboa.util.cascade + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember + +@Composable +fun rememberCascadeState(): CascadeState { + return remember { CascadeState() } +} + +/** + * The state of a [CascadeDropdownMenu]. + */ +@Stable +class CascadeState internal constructor() { + private val backStack = mutableStateListOf() + + fun navigateBack() { + backStack.removeLast() + } + + fun resetBackStack() { + backStack.clear() + } + + fun canNavigateBack(): Boolean { + return backStack.isNotEmpty() + } + + internal fun navigateTo(entry: CascadeBackStackEntry) { + backStack.add(entry) + } + + internal fun backStackSnapshot(): BackStackSnapshot { + return BackStackSnapshot( + topMostEntry = backStack.lastOrNull(), + backStackSize = backStack.size, + ) + } +} + +@Immutable +internal class CascadeBackStackEntry( + val header: @Composable () -> Unit, + val childrenContent: @Composable CascadeColumnScope.() -> Unit, +) + +@Immutable +internal data class BackStackSnapshot( + val topMostEntry: CascadeBackStackEntry?, + val backStackSize: Int, +) diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/AnimateEntryExit.kt b/app/src/main/java/com/jerboa/util/cascade/internal/AnimateEntryExit.kt new file mode 100644 index 000000000..8a441d921 --- /dev/null +++ b/app/src/main/java/com/jerboa/util/cascade/internal/AnimateEntryExit.kt @@ -0,0 +1,184 @@ +package com.jerboa.util.cascade.internal + +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.addOutline +import androidx.compose.ui.graphics.asAndroidPath +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection + +private const val InTransitionDuration = 300 +private const val OutTransitionDuration = 300 + +@Composable +internal fun AnimateEntryExit( + modifier: Modifier = Modifier, + expandedStates: MutableTransitionState, + transformOriginState: State, + shadowElevation: Dp, + content: @Composable () -> Unit, +) { + val isExpandedTransition = updateTransition(expandedStates, label = "CascadeDropDownMenu") + val scale by isExpandedTransition.animateFloat( + transitionSpec = { + tween(if (false isTransitioningTo true) InTransitionDuration else OutTransitionDuration) + }, + label = "scale", + targetValueByState = { if (it) 1f else 0f }, + ) + val alpha by isExpandedTransition.animateFloat( + transitionSpec = { + tween(if (false isTransitioningTo true) InTransitionDuration else OutTransitionDuration) + }, + label = "alpha", + targetValueByState = { if (it) 1f else 0f }, + ) + val reveal by isExpandedTransition.animateFloat( + transitionSpec = { + tween((if (false isTransitioningTo true) InTransitionDuration * 1.2 else OutTransitionDuration * 0.8).toInt()) + }, + label = "clip", + targetValueByState = { if (it) 1f else 0.25f }, + ) + + val shape = MaterialTheme.shapes.extraSmall + + val clippingShape = remember(reveal) { + object : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density, + ): Outline { + val outline = shape.createOutline( + size = size, + layoutDirection = layoutDirection, + density = density, + ) + return when (outline) { + is Outline.Generic, + is Outline.Rectangle, + -> { + Outline.Rectangle( + Rect(Offset.Zero, size = size.copy(height = size.height * reveal)), + ) + } + + is Outline.Rounded -> { + Outline.Rounded( + RoundRect( + rect = Rect(Offset.Zero, size = size.copy(height = size.height * reveal)), + topLeft = outline.roundRect.topLeftCornerRadius, + topRight = outline.roundRect.topRightCornerRadius, + bottomRight = outline.roundRect.bottomRightCornerRadius, + bottomLeft = outline.roundRect.bottomLeftCornerRadius, + ), + ) + } + } + } + } + } + + Box( + modifier.scale(scale, transformOrigin = transformOriginState.value), + ) { + // Drop shadows and content are drawn in separate sibling layouts because: + // + // - shadow().alpha() will not apply alpha to shadows. + // + // - alpha().shadow() will cause shadows to get clipped of content bounds + // because its usage of graphicsLayer(). + // + // - shadow() applied on the parent will cause shadows to get clipped outside + // of Popup's bounds, e.g., behind the status bar. + // + // FWIW material3.DropdownMenu() also suffers from these same problems. Its + // shadows get clipped during entry/exit transitions, but the 8dp shadows are + // small enough for the clipping to go unnoticed. + Box( + Modifier + .matchParentSize() + // Because the drop shadows are drawn separately from the popup's content, + // this layout's inner shadows must be clipped out to prevent it from + // showing up behind the translucent content. + .then(alpha < 1f) { clipDifference(clippingShape) } + .shadow( + elevation = shadowElevation, + shape = clippingShape, + clip = false, + ambientColor = Color.Black.copy(alpha = alpha), + spotColor = Color.Black.copy(alpha = alpha), + ), + ) + + Box( + Modifier + .alpha(alpha) + .clip(clippingShape), + ) { + content() + } + } +} + +// Like Modifier.clip() but uses ClipOp.Difference instead of ClipOp.Intersect. +private fun Modifier.clipDifference(shape: Shape): Modifier = composed { + val path = remember { Path() } + drawWithCache { + path.asAndroidPath().rewind() // rewind() is faster than reset(). + path.addOutline( + shape.createOutline( + size = size, + layoutDirection = layoutDirection, + density = this, + ), + ) + onDrawWithContent { + clipPath(path, ClipOp.Difference) { + this@onDrawWithContent.drawContent() + } + } + } +} + +@Stable +fun Modifier.scale(scale: Float, transformOrigin: TransformOrigin): Modifier { + return if (scale != 1f) { + graphicsLayer( + scaleX = scale, + scaleY = scale, + transformOrigin = transformOrigin, + ) + } else { + this + } +} diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/CoercePositiveValues.kt b/app/src/main/java/com/jerboa/util/cascade/internal/CoercePositiveValues.kt new file mode 100644 index 000000000..95b225679 --- /dev/null +++ b/app/src/main/java/com/jerboa/util/cascade/internal/CoercePositiveValues.kt @@ -0,0 +1,42 @@ +package com.jerboa.util.cascade.internal + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.window.PopupPositionProvider + +// TODO: this can be removed when https://issuetracker.google.com/issues/265547235 is fixed. +@Immutable +internal data class CoercePositiveValues( + val delegate: PopupPositionProvider, +) : PopupPositionProvider { + + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val position = delegate.calculatePosition( + anchorBounds = anchorBounds, + windowSize = windowSize, + layoutDirection = layoutDirection, + popupContentSize = popupContentSize, + ) + return position.copy( + x = maxOf(0, position.x), + y = maxOf(0, position.y), + ) + } + + companion object { + internal fun correctMenuBounds(menuBounds: IntRect): IntRect { + return menuBounds.translate( + translateX = minOf(0, menuBounds.left) * -1, + translateY = minOf(0, menuBounds.top) * -1, + ) + } + } +} diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/DropdownMenuPositionProvider.kt b/app/src/main/java/com/jerboa/util/cascade/internal/DropdownMenuPositionProvider.kt new file mode 100644 index 000000000..a2302668d --- /dev/null +++ b/app/src/main/java/com/jerboa/util/cascade/internal/DropdownMenuPositionProvider.kt @@ -0,0 +1,114 @@ +package com.jerboa.util.cascade.internal + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupPositionProvider +import kotlin.math.max +import kotlin.math.min + +/** + * Copied from [material3](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Menu.kt?q=file:androidx%2Fcompose%2Fmaterial3%2FMenu.kt%20class:androidx.compose.material3.DropdownMenuPositionProvider). + */ +@Immutable +internal data class DropdownMenuPositionProvider( + val contentOffset: DpOffset, + val density: Density, + val onPositionCalculated: (anchorBounds: IntRect, menuBounds: IntRect) -> Unit = { _, _ -> }, +) : PopupPositionProvider { + + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + // The min margin above and below the menu, relative to the screen. + val verticalMargin = with(density) { MenuVerticalMargin.roundToPx() } + // The content offset specified using the dropdown offset parameter. + val contentOffsetX = with(density) { contentOffset.x.roundToPx() } + val contentOffsetY = with(density) { contentOffset.y.roundToPx() } + + // Compute horizontal position. + val toRight = anchorBounds.left + contentOffsetX + val toLeft = anchorBounds.right - contentOffsetX - popupContentSize.width + val toDisplayRight = windowSize.width - popupContentSize.width + val toDisplayLeft = 0 + val x = if (layoutDirection == LayoutDirection.Ltr) { + sequenceOf( + toRight, + toLeft, + // If the anchor gets outside of the window on the left, we want to position + // toDisplayLeft for proximity to the anchor. Otherwise, toDisplayRight. + if (anchorBounds.left >= 0) toDisplayRight else toDisplayLeft, + ) + } else { + sequenceOf( + toLeft, + toRight, + // If the anchor gets outside of the window on the right, we want to position + // toDisplayRight for proximity to the anchor. Otherwise, toDisplayLeft. + if (anchorBounds.right <= windowSize.width) toDisplayLeft else toDisplayRight, + ) + }.firstOrNull { + it >= 0 && it + popupContentSize.width <= windowSize.width + } ?: toLeft + + // Compute vertical position. + val toBottom = maxOf(anchorBounds.bottom + contentOffsetY, verticalMargin) + val toTop = anchorBounds.top - contentOffsetY - popupContentSize.height + val toCenter = anchorBounds.top - popupContentSize.height / 2 + val toDisplayBottom = windowSize.height - popupContentSize.height - verticalMargin + val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull { + it >= verticalMargin && + it + popupContentSize.height <= windowSize.height - verticalMargin + } ?: toTop + + onPositionCalculated( + anchorBounds, + IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height), + ) + return IntOffset(x, y) + } +} + +internal val MenuVerticalMargin = 48.dp + +internal fun calculateTransformOrigin( + parentBounds: IntRect, + menuBounds: IntRect, +): TransformOrigin { + val pivotX = when { + menuBounds.left >= parentBounds.right -> 0f + menuBounds.right <= parentBounds.left -> 1f + menuBounds.width == 0 -> 0f + else -> { + val intersectionCenter = + ( + max(parentBounds.left, menuBounds.left) + + min(parentBounds.right, menuBounds.right) + ) / 2 + (intersectionCenter - menuBounds.left).toFloat() / menuBounds.width + } + } + val pivotY = when { + menuBounds.top >= parentBounds.bottom -> 0f + menuBounds.bottom <= parentBounds.top -> 1f + menuBounds.height == 0 -> 0f + else -> { + val intersectionCenter = + ( + max(parentBounds.top, menuBounds.top) + + min(parentBounds.bottom, menuBounds.bottom) + ) / 2 + (intersectionCenter - menuBounds.top).toFloat() / menuBounds.height + } + } + return TransformOrigin(pivotX, pivotY) +} diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/PositionPopupContent.kt b/app/src/main/java/com/jerboa/util/cascade/internal/PositionPopupContent.kt new file mode 100644 index 000000000..620fb53f5 --- /dev/null +++ b/app/src/main/java/com/jerboa/util/cascade/internal/PositionPopupContent.kt @@ -0,0 +1,81 @@ +package com.jerboa.util.cascade.internal + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.absoluteOffset +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.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.round +import androidx.compose.ui.unit.toOffset +import androidx.compose.ui.window.PopupPositionProvider +import kotlin.math.roundToInt + +@Composable +internal fun PositionPopupContent( + modifier: Modifier = Modifier, + positionProvider: PopupPositionProvider, + anchorBounds: ScreenRelativeBounds?, + content: @Composable () -> Unit, +) { + val popupView = LocalView.current + val layoutDirection = LocalLayoutDirection.current + var contentPosition: IntOffset? by remember { mutableStateOf(null) } + + Box(modifier) { + Box( + Modifier + .onGloballyPositioned { coordinates -> + val popupContentBounds = ScreenRelativeBounds(coordinates, owner = popupView) + + if (anchorBounds != null) { + contentPosition = positionProvider + .calculatePosition( + anchorBounds = anchorBounds.boundsInRoot.roundToIntRect(), + // material3 uses View#getWindowVisibleDisplayFrame() for calculating window size, + // but that produces infinite-like values for windows that have FLAG_LAYOUT_NO_LIMITS set. + // material3 ends up looking okay because WindowManager sanitizes bad values. + windowSize = anchorBounds.root.layoutBoundsInWindow.intSize(), + layoutDirection = layoutDirection, + popupContentSize = coordinates.size, + ) + .let { position -> + // Material3's DropdownMenuPositionProvider was written to calculate + // a position in the anchor's window. Cascade will have to adjust the + // position to use it inside the popup's window. + val positionInAnchorWindow = ScreenRelativeOffset( + positionInRoot = position.toOffset(), + root = anchorBounds.root, + ) + positionInAnchorWindow + .positionInWindowOf(popupContentBounds) + .round() + } + } + } + // Hide the popup until it can be positioned. + .alpha(if (contentPosition != null) 1f else 0f) + .absoluteOffset { contentPosition ?: IntOffset.Zero }, + ) { + content() + } + } +} + +private fun Rect.roundToIntRect(): IntRect { + return IntRect(topLeft = topLeft.round(), bottomRight = bottomRight.round()) +} + +private fun Rect.intSize(): IntSize { + return IntSize(width = width.roundToInt(), height = height.roundToInt()) +} diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/ScreenRelativeBounds.kt b/app/src/main/java/com/jerboa/util/cascade/internal/ScreenRelativeBounds.kt new file mode 100644 index 000000000..5f3614e03 --- /dev/null +++ b/app/src/main/java/com/jerboa/util/cascade/internal/ScreenRelativeBounds.kt @@ -0,0 +1,60 @@ +package com.jerboa.util.cascade.internal + +import android.view.View +import androidx.compose.runtime.Immutable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.unit.toSize + +@Immutable +internal data class ScreenRelativeBounds( + val boundsInRoot: Rect, + val root: RootLayoutCoordinatesInfo, +) + +@Immutable +internal data class ScreenRelativeOffset( + val positionInRoot: Offset, + val root: RootLayoutCoordinatesInfo, +) + +@Immutable +internal data class RootLayoutCoordinatesInfo( + val layoutBoundsInWindow: Rect, + val windowPositionOnScreen: Offset, +) { + val layoutPositionInWindow: Offset get() = layoutBoundsInWindow.topLeft +} + +internal fun ScreenRelativeBounds(coordinates: LayoutCoordinates, owner: View): ScreenRelativeBounds { + return ScreenRelativeBounds( + boundsInRoot = Rect( + offset = coordinates.positionInRoot(), + size = coordinates.size.toSize(), + ), + root = RootLayoutCoordinatesInfo( + layoutBoundsInWindow = coordinates.findRootCoordinates().boundsInWindow(), + windowPositionOnScreen = run { + owner.rootView.getLocationOnScreen(intArrayBuffer) + Offset(x = intArrayBuffer[0].toFloat(), y = intArrayBuffer[1].toFloat()) + }, + ), + ) +} + +// I do not expect this to be shared across threads to need any synchronization. +private val intArrayBuffer = IntArray(size = 2) + +/** + * Calculate a position in another window such that its visual location on screen + * remains unchanged. That is, its offset from screen's 0,0 remains the same. + * */ +internal fun ScreenRelativeOffset.positionInWindowOf(other: ScreenRelativeBounds): Offset { + return positionInRoot - + (other.root.layoutPositionInWindow - root.layoutPositionInWindow) - + (other.root.windowPositionOnScreen - root.windowPositionOnScreen) +} diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/cascadeTransitionSpec.kt b/app/src/main/java/com/jerboa/util/cascade/internal/cascadeTransitionSpec.kt new file mode 100644 index 000000000..8db2554c1 --- /dev/null +++ b/app/src/main/java/com/jerboa/util/cascade/internal/cascadeTransitionSpec.kt @@ -0,0 +1,33 @@ +package com.jerboa.util.cascade.internal + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.LayoutDirection.Ltr +import com.jerboa.util.cascade.BackStackSnapshot + +internal fun AnimatedContentTransitionScope.cascadeTransitionSpec( + layoutDirection: LayoutDirection, +): ContentTransform { + val navigatingForward = targetState.backStackSize > initialState.backStackSize + + val inverseMultiplier = if (layoutDirection == Ltr) 1 else -1 + val initialOffset = { width: Int -> + inverseMultiplier * if (navigatingForward) width else -width / 4 + } + val targetOffset = { width: Int -> + inverseMultiplier * if (navigatingForward) -width / 4 else width + } + + val duration = 350 + return ContentTransform( + targetContentEnter = slideInHorizontally(tween(duration), initialOffset), + initialContentExit = slideOutHorizontally(tween(duration), targetOffset), + targetContentZIndex = targetState.backStackSize.toFloat(), + sizeTransform = SizeTransform(sizeAnimationSpec = { _, _ -> tween(duration) }), + ) +} diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/clickableWithoutRipple.kt b/app/src/main/java/com/jerboa/util/cascade/internal/clickableWithoutRipple.kt new file mode 100644 index 000000000..c85036c12 --- /dev/null +++ b/app/src/main/java/com/jerboa/util/cascade/internal/clickableWithoutRipple.kt @@ -0,0 +1,19 @@ +package com.jerboa.util.cascade.internal + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed + +internal fun Modifier.clickableWithoutRipple(onClick: () -> Unit) = composed { + clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onClick, + ) +} + +internal inline fun Modifier.then(predicate: Boolean, modifier: Modifier.() -> Modifier): Modifier { + return if (predicate) modifier() else this +} diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/popupProperties.kt b/app/src/main/java/com/jerboa/util/cascade/internal/popupProperties.kt new file mode 100644 index 000000000..72ad3fadb --- /dev/null +++ b/app/src/main/java/com/jerboa/util/cascade/internal/popupProperties.kt @@ -0,0 +1,19 @@ +package com.jerboa.util.cascade.internal + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.PopupProperties + +@OptIn(ExperimentalComposeUiApi::class) +fun PopupProperties.copy( + usePlatformDefaultWidth: Boolean, +): PopupProperties { + return PopupProperties( + focusable = focusable, + dismissOnBackPress = dismissOnBackPress, + dismissOnClickOutside = dismissOnClickOutside, + securePolicy = securePolicy, + excludeFromSystemGesture = excludeFromSystemGesture, + clippingEnabled = clippingEnabled, + usePlatformDefaultWidth = usePlatformDefaultWidth, + ) +} From 8332e22083f701eaf3c19706ad9909502d5da0d3 Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Thu, 31 Aug 2023 01:45:00 +0200 Subject: [PATCH 06/10] Replace all relevant dialogs with dropdown menus --- app/src/main/java/com/jerboa/MainActivity.kt | 8 + app/src/main/java/com/jerboa/Utils.kt | 88 ----- .../java/com/jerboa/datatypes/types/Others.kt | 8 +- .../ui/components/comment/CommentNode.kt | 178 +-------- .../comment/CommentOptionsDropdown.kt | 164 ++++++++ .../comment/mentionnode/CommentMentionNode.kt | 116 +----- .../CommentMentionOptionsDropdown.kt | 130 +++++++ .../comment/replynode/CommentReplyNode.kt | 102 +---- .../replynode/CommentReplyOptionsDropdown.kt | 129 +++++++ .../jerboa/ui/components/common/Dialogs.kt | 116 ------ .../ui/components/common/DropdownMenu.kt | 193 ++++++++++ .../ui/components/common/LinkDropDownMenu.kt | 244 +++++------- .../jerboa/ui/components/common/Modifiers.kt | 116 ++++++ .../ui/components/common/PictrsImage.kt | 16 - .../ui/components/community/Community.kt | 58 +-- .../com/jerboa/ui/components/home/Home.kt | 58 +-- .../ui/components/inbox/InboxActivity.kt | 2 +- .../com/jerboa/ui/components/login/Login.kt | 2 +- .../ui/components/person/PersonProfile.kt | 58 +-- .../person/PersonProfileActivity.kt | 2 +- .../jerboa/ui/components/post/PostActivity.kt | 36 +- .../jerboa/ui/components/post/PostListing.kt | 355 ++--------------- .../post/composables/PostOptionsDropdown.kt | 363 ++++++++++++++++++ .../java/com/jerboa/util/cascade/Cascade.kt | 12 +- .../util/cascade/CustomCascadeDropdown.kt | 77 ++++ app/src/main/res/values/strings.xml | 2 + 26 files changed, 1430 insertions(+), 1203 deletions(-) create mode 100644 app/src/main/java/com/jerboa/ui/components/comment/CommentOptionsDropdown.kt create mode 100644 app/src/main/java/com/jerboa/ui/components/comment/mentionnode/CommentMentionOptionsDropdown.kt create mode 100644 app/src/main/java/com/jerboa/ui/components/comment/replynode/CommentReplyOptionsDropdown.kt create mode 100644 app/src/main/java/com/jerboa/ui/components/common/Modifiers.kt create mode 100644 app/src/main/java/com/jerboa/ui/components/post/composables/PostOptionsDropdown.kt create mode 100644 app/src/main/java/com/jerboa/util/cascade/CustomCascadeDropdown.kt diff --git a/app/src/main/java/com/jerboa/MainActivity.kt b/app/src/main/java/com/jerboa/MainActivity.kt index 927e15f52..205531e57 100644 --- a/app/src/main/java/com/jerboa/MainActivity.kt +++ b/app/src/main/java/com/jerboa/MainActivity.kt @@ -168,6 +168,14 @@ class MainActivity : AppCompatActivity() { appSettings.usePrivateTabs, ) +// CommentSortOptionsDropdownTestOld( +// expanded = appState.linkDropdownExpanded.value != null, +// onDismissRequest = appState::hideLinkPopup, +// siteVersion = MINIMUM_API_VERSION, +// onClickSortType = {}, +// selectedSortType = SortType.Hot +// ) + ShowChangelog(appSettingsViewModel = appSettingsViewModel) when (val siteRes = siteViewModel.siteRes) { diff --git a/app/src/main/java/com/jerboa/Utils.kt b/app/src/main/java/com/jerboa/Utils.kt index f7a1c50be..067feced0 100644 --- a/app/src/main/java/com/jerboa/Utils.kt +++ b/app/src/main/java/com/jerboa/Utils.kt @@ -28,35 +28,21 @@ import androidx.activity.result.contract.ActivityResultContract import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatDelegate import androidx.browser.customtabs.CustomTabsIntent -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.pager.PagerState import androidx.compose.material3.DrawerState import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.TabPosition import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.Autofill -import androidx.compose.ui.autofill.AutofillNode -import androidx.compose.ui.autofill.AutofillTree -import androidx.compose.ui.autofill.AutofillType import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.layout -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.lerp import androidx.core.os.LocaleListCompat import androidx.core.util.PatternsCompat import androidx.lifecycle.LiveData @@ -870,52 +856,6 @@ enum class PostType { } } -@OptIn(ExperimentalFoundationApi::class) -fun Modifier.pagerTabIndicatorOffset2( - pagerState: PagerState, - tabPositions: List, - pageIndexMapping: (Int) -> Int = { it }, -): Modifier = layout { measurable, constraints -> - if (tabPositions.isEmpty()) { - // If there are no pages, nothing to show - layout(constraints.maxWidth, 0) {} - } else { - val currentPage = minOf(tabPositions.lastIndex, pageIndexMapping(pagerState.currentPage)) - val currentTab = tabPositions[currentPage] - val previousTab = tabPositions.getOrNull(currentPage - 1) - val nextTab = tabPositions.getOrNull(currentPage + 1) - val fraction = pagerState.currentPageOffsetFraction - val indicatorWidth = if (fraction > 0 && nextTab != null) { - lerp(currentTab.width, nextTab.width, fraction).roundToPx() - } else if (fraction < 0 && previousTab != null) { - lerp(currentTab.width, previousTab.width, -fraction).roundToPx() - } else { - currentTab.width.roundToPx() - } - val indicatorOffset = if (fraction > 0 && nextTab != null) { - lerp(currentTab.left, nextTab.left, fraction).roundToPx() - } else if (fraction < 0 && previousTab != null) { - lerp(currentTab.left, previousTab.left, -fraction).roundToPx() - } else { - currentTab.left.roundToPx() - } - val placeable = measurable.measure( - Constraints( - minWidth = indicatorWidth, - maxWidth = indicatorWidth, - minHeight = 0, - maxHeight = constraints.maxHeight, - ), - ) - layout(constraints.maxWidth, maxOf(placeable.height, constraints.minHeight)) { - placeable.placeRelative( - indicatorOffset, - maxOf(constraints.minHeight - placeable.height, 0), - ) - } - } -} - fun isSameInstance(url: String, instance: String): Boolean { return hostName(url) == instance } @@ -1006,34 +946,6 @@ fun saveMediaP( MediaScannerConnection.scanFile(context, arrayOf(dest.absolutePath), mimeTypes, null) } -@OptIn(ExperimentalComposeUiApi::class) -fun Modifier.onAutofill( - tree: AutofillTree, - autofill: Autofill?, - autofillTypes: ImmutableList, - onFill: (String) -> Unit, -): Modifier { - val autofillNode = AutofillNode( - autofillTypes = autofillTypes, - onFill = onFill, - ) - tree += autofillNode - - return this - .onGloballyPositioned { - autofillNode.boundingBox = it.boundsInWindow() - } - .onFocusChanged { focusState -> - autofill?.run { - if (focusState.isFocused) { - requestAutofillForNode(autofillNode) - } else { - cancelAutofillForNode(autofillNode) - } - } - } -} - /** * Converts a scalable pixel (sp) to an actual pixel (px) */ diff --git a/app/src/main/java/com/jerboa/datatypes/types/Others.kt b/app/src/main/java/com/jerboa/datatypes/types/Others.kt index e448c154e..e837d6a90 100644 --- a/app/src/main/java/com/jerboa/datatypes/types/Others.kt +++ b/app/src/main/java/com/jerboa/datatypes/types/Others.kt @@ -39,7 +39,7 @@ enum class RegistrationMode { enum class SortType( @StringRes val shortForm: Int, @StringRes val longForm: Int, - val icon: ImageVector? = null, + val icon: ImageVector, val version: String = MINIMUM_API_VERSION, ) { /** @@ -225,14 +225,14 @@ enum class SortType( ; companion object { - val getSupportedSortTypes = { siteVersion: String -> values().filter { compareVersions(siteVersion, it.version) >= 0 } } + val getSupportedSortTypes = { siteVersion: String -> entries.filter { compareVersions(siteVersion, it.version) >= 0 } } } } /** * Different comment sort types used in lemmy. */ -enum class CommentSortType(val text: Int, val icon: ImageVector? = null, val version: String = MINIMUM_API_VERSION) { +enum class CommentSortType(val text: Int, val icon: ImageVector, val version: String = MINIMUM_API_VERSION) { /** * Comments sorted by a decaying rank. */ @@ -265,7 +265,7 @@ enum class CommentSortType(val text: Int, val icon: ImageVector? = null, val ver ; companion object { - val getSupportedSortTypes = { siteVersion: String -> values().filter { compareVersions(siteVersion, it.version) >= 0 } } + val getSupportedSortTypes = { siteVersion: String -> entries.filter { compareVersions(siteVersion, it.version) >= 0 } } } } diff --git a/app/src/main/java/com/jerboa/ui/components/comment/CommentNode.kt b/app/src/main/java/com/jerboa/ui/components/comment/CommentNode.kt index 24b0b2fa5..6e20ba163 100644 --- a/app/src/main/java/com/jerboa/ui/components/comment/CommentNode.kt +++ b/app/src/main/java/com/jerboa/ui/components/comment/CommentNode.kt @@ -2,7 +2,6 @@ package com.jerboa.ui.components.comment import android.view.View import android.widget.TextView -import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically @@ -20,20 +19,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.Comment -import androidx.compose.material.icons.outlined.ContentCopy -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Description -import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material.icons.outlined.Flag -import androidx.compose.material.icons.outlined.Forum -import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Person -import androidx.compose.material.icons.outlined.Restore -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Divider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -47,10 +36,8 @@ 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.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -63,7 +50,6 @@ import com.jerboa.border import com.jerboa.buildCommentsTree import com.jerboa.calculateCommentOffset import com.jerboa.calculateNewInstantScores -import com.jerboa.copyToClipboard import com.jerboa.datatypes.sampleCommentView import com.jerboa.datatypes.sampleCommunity import com.jerboa.datatypes.samplePost @@ -75,7 +61,6 @@ import com.jerboa.db.entity.AnonAccount import com.jerboa.isPostCreator import com.jerboa.ui.components.common.ActionBarButton import com.jerboa.ui.components.common.CommentOrPostNodeHeader -import com.jerboa.ui.components.common.IconAndTextDrawerItem import com.jerboa.ui.components.common.MarkdownHelper import com.jerboa.ui.components.common.MyMarkdownText import com.jerboa.ui.components.common.VoteGeneric @@ -545,37 +530,16 @@ fun CommentFooterLine( var showMoreOptions by remember { mutableStateOf(false) } if (showMoreOptions) { - CommentOptionsDialog( + CommentOptionsDropdown( commentView = commentView, onDismissRequest = { showMoreOptions = false }, - onViewSourceClick = { - showMoreOptions = false - onViewSourceClick() - }, - onEditCommentClick = { - showMoreOptions = false - onEditCommentClick(commentView) - }, - onDeleteCommentClick = { - showMoreOptions = false - onDeleteCommentClick(commentView) - }, - onReportClick = { - showMoreOptions = false - onReportClick(commentView) - }, - onBlockCreatorClick = { - showMoreOptions = false - onBlockCreatorClick(commentView.creator) - }, - onCommentLinkClick = { - showMoreOptions = false - onCommentLinkClick(commentView) - }, - onPersonClick = { - showMoreOptions = false - onPersonClick(commentView.creator.id) - }, + onViewSourceClick = onViewSourceClick, + onEditCommentClick = onEditCommentClick, + onDeleteCommentClick = onDeleteCommentClick, + onReportClick = onReportClick, + onBlockCreatorClick = onBlockCreatorClick, + onCommentLinkClick = onCommentLinkClick, + onPersonClick = onPersonClick, isCreator = account.id == commentView.creator.id, viewSource = viewSource, ) @@ -696,132 +660,6 @@ fun CommentNodesPreview() { ) } -@Composable -fun CommentOptionsDialog( - onDismissRequest: () -> Unit, - onViewSourceClick: () -> Unit, - onEditCommentClick: () -> Unit, - onDeleteCommentClick: () -> Unit, - onReportClick: () -> Unit, - onBlockCreatorClick: () -> Unit, - onCommentLinkClick: () -> Unit, - onPersonClick: () -> Unit, - isCreator: Boolean, - commentView: CommentView, - viewSource: Boolean, -) { - val localClipboardManager = LocalClipboardManager.current - val ctx = LocalContext.current - - AlertDialog( - onDismissRequest = onDismissRequest, - text = { - Column { - IconAndTextDrawerItem( - text = stringResource(R.string.comment_node_goto_comment), - icon = Icons.Outlined.Forum, - onClick = onCommentLinkClick, - ) - IconAndTextDrawerItem( - text = stringResource( - R.string.comment_node_go_to, - commentView.creator.name, - ), - icon = Icons.Outlined.Person, - onClick = onPersonClick, - ) - IconAndTextDrawerItem( - text = if (viewSource) { - stringResource(R.string.comment_node_view_original) - } else { - stringResource(R.string.comment_node_view_source) - }, - icon = Icons.Outlined.Description, - onClick = onViewSourceClick, - ) - IconAndTextDrawerItem( - text = stringResource(R.string.comment_node_copy_permalink), - icon = Icons.Outlined.Link, - onClick = { - val permalink = commentView.comment.ap_id - localClipboardManager.setText(AnnotatedString(permalink)) - Toast.makeText( - ctx, - ctx.getString(R.string.comment_node_permalink_copied), - Toast.LENGTH_SHORT, - ).show() - onDismissRequest() - }, - ) - IconAndTextDrawerItem( - text = stringResource(R.string.comment_node_copy_comment), - icon = Icons.Outlined.ContentCopy, - onClick = { - if (copyToClipboard(ctx, commentView.comment.content, "comment")) { - Toast.makeText(ctx, ctx.getString(R.string.comment_node_comment_copied), Toast.LENGTH_SHORT).show() - } else { - Toast.makeText(ctx, ctx.getString(R.string.generic_error), Toast.LENGTH_SHORT).show() - } - onDismissRequest() - }, - ) - if (!isCreator) { - IconAndTextDrawerItem( - text = stringResource(R.string.comment_node_report_comment), - icon = Icons.Outlined.Flag, - onClick = onReportClick, - ) - IconAndTextDrawerItem( - text = stringResource(R.string.comment_node_block, commentView.creator.name), - icon = Icons.Outlined.Block, - onClick = onBlockCreatorClick, - ) - } - if (isCreator) { - IconAndTextDrawerItem( - text = stringResource(R.string.comment_node_edit), - icon = Icons.Outlined.Edit, - onClick = onEditCommentClick, - ) - val deleted = commentView.comment.deleted - if (deleted) { - IconAndTextDrawerItem( - text = stringResource(R.string.comment_node_restore), - icon = Icons.Outlined.Restore, - onClick = onDeleteCommentClick, - ) - } else { - IconAndTextDrawerItem( - text = stringResource(R.string.comment_node_delete), - icon = Icons.Outlined.Delete, - onClick = onDeleteCommentClick, - ) - } - } - } - }, - confirmButton = {}, - ) -} - -@Preview -@Composable -fun CommentOptionsDialogPreview() { - CommentOptionsDialog( - isCreator = true, - commentView = sampleCommentView, - onDismissRequest = {}, - onEditCommentClick = {}, - onDeleteCommentClick = {}, - onReportClick = {}, - onViewSourceClick = {}, - onCommentLinkClick = {}, - onPersonClick = {}, - onBlockCreatorClick = {}, - viewSource = false, - ) -} - @Composable fun ShowMoreChildren( commentView: CommentView, diff --git a/app/src/main/java/com/jerboa/ui/components/comment/CommentOptionsDropdown.kt b/app/src/main/java/com/jerboa/ui/components/comment/CommentOptionsDropdown.kt new file mode 100644 index 000000000..0b01a4ce2 --- /dev/null +++ b/app/src/main/java/com/jerboa/ui/components/comment/CommentOptionsDropdown.kt @@ -0,0 +1,164 @@ +package com.jerboa.ui.components.comment + +import android.widget.Toast +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.Comment +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.CopyAll +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Flag +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Restore +import androidx.compose.material3.Divider +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import com.jerboa.R +import com.jerboa.copyToClipboard +import com.jerboa.datatypes.types.CommentView +import com.jerboa.datatypes.types.Person +import com.jerboa.datatypes.types.PersonId +import com.jerboa.ui.components.common.PopupMenuItem +import com.jerboa.util.cascade.CascadeCenteredDropdownMenu + +@Composable +fun CommentOptionsDropdown( + commentView: CommentView, + onDismissRequest: () -> Unit, + onCommentLinkClick: (CommentView) -> Unit, + onPersonClick: (PersonId) -> Unit, + onViewSourceClick: () -> Unit, + onEditCommentClick: (CommentView) -> Unit, + onDeleteCommentClick: (CommentView) -> Unit, + onBlockCreatorClick: (Person) -> Unit, + onReportClick: (CommentView) -> Unit, + isCreator: Boolean, + viewSource: Boolean, +) { + val localClipboardManager = LocalClipboardManager.current + val ctx = LocalContext.current + + CascadeCenteredDropdownMenu( + expanded = true, + onDismissRequest = onDismissRequest, + ) { + PopupMenuItem( + text = stringResource(R.string.comment_node_goto_comment), + icon = Icons.Outlined.Comment, + onClick = { + onDismissRequest() + onCommentLinkClick(commentView) + }, + ) + + PopupMenuItem( + text = stringResource(R.string.comment_node_go_to, commentView.creator.name), + icon = Icons.Outlined.Person, + onClick = { + onDismissRequest() + onPersonClick(commentView.creator.id) + }, + ) + + PopupMenuItem( + text = stringResource(R.string.copy), + icon = Icons.Outlined.CopyAll, + ) { + PopupMenuItem( + text = stringResource(R.string.comment_node_copy_permalink), + icon = Icons.Outlined.Link, + onClick = { + onDismissRequest() + val permalink = commentView.comment.ap_id + localClipboardManager.setText(AnnotatedString(permalink)) + Toast.makeText( + ctx, + ctx.getString(R.string.comment_node_permalink_copied), + Toast.LENGTH_SHORT, + ).show() + }, + ) + PopupMenuItem( + text = stringResource(R.string.comment_node_copy_comment), + icon = Icons.Outlined.ContentCopy, + onClick = { + onDismissRequest() + if (copyToClipboard(ctx, commentView.comment.content, "comment")) { + Toast.makeText(ctx, ctx.getString(R.string.comment_node_comment_copied), Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(ctx, ctx.getString(R.string.generic_error), Toast.LENGTH_SHORT).show() + } + }, + ) + } + + PopupMenuItem( + text = if (viewSource) { + stringResource(R.string.comment_node_view_original) + } else { + stringResource(R.string.comment_node_view_source) + }, + icon = Icons.Outlined.Description, + onClick = { + onDismissRequest() + onViewSourceClick() + }, + ) + + Divider() + + if (isCreator) { + PopupMenuItem( + text = stringResource(R.string.comment_node_edit), + icon = Icons.Outlined.Edit, + onClick = { + onDismissRequest() + onEditCommentClick(commentView) + }, + ) + + if (commentView.comment.deleted) { + PopupMenuItem( + text = stringResource(R.string.comment_node_restore), + icon = Icons.Outlined.Restore, + onClick = { + onDismissRequest() + onDeleteCommentClick(commentView) + }, + ) + } else { + PopupMenuItem( + text = stringResource(R.string.comment_node_delete), + icon = Icons.Outlined.Delete, + onClick = { + onDismissRequest() + onDeleteCommentClick(commentView) + }, + ) + } + } else { + PopupMenuItem( + text = stringResource(R.string.comment_node_block, commentView.creator.name), + icon = Icons.Outlined.Block, + onClick = { + onDismissRequest() + onBlockCreatorClick(commentView.creator) + }, + ) + PopupMenuItem( + text = stringResource(R.string.comment_node_report_comment), + icon = Icons.Outlined.Flag, + onClick = { + onDismissRequest() + onReportClick(commentView) + }, + ) + } + } +} diff --git a/app/src/main/java/com/jerboa/ui/components/comment/mentionnode/CommentMentionNode.kt b/app/src/main/java/com/jerboa/ui/components/comment/mentionnode/CommentMentionNode.kt index cbde0b7d5..41f0772e8 100644 --- a/app/src/main/java/com/jerboa/ui/components/comment/mentionnode/CommentMentionNode.kt +++ b/app/src/main/java/com/jerboa/ui/components/comment/mentionnode/CommentMentionNode.kt @@ -1,6 +1,5 @@ package com.jerboa.ui.components.comment.mentionnode -import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically @@ -11,18 +10,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.Comment -import androidx.compose.material.icons.outlined.ContentCopy -import androidx.compose.material.icons.outlined.Description -import androidx.compose.material.icons.outlined.Flag import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.MarkChatRead import androidx.compose.material.icons.outlined.MarkChatUnread import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Person -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Divider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -31,14 +25,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import com.jerboa.R import com.jerboa.VoteType -import com.jerboa.copyToClipboard import com.jerboa.datatypes.samplePersonMentionView import com.jerboa.datatypes.types.Community import com.jerboa.datatypes.types.Person @@ -48,7 +38,6 @@ import com.jerboa.ui.components.comment.CommentBody import com.jerboa.ui.components.comment.PostAndCommunityContextHeader import com.jerboa.ui.components.common.ActionBarButton import com.jerboa.ui.components.common.CommentOrPostNodeHeader -import com.jerboa.ui.components.common.IconAndTextDrawerItem import com.jerboa.ui.components.common.VoteGeneric import com.jerboa.ui.theme.LARGE_PADDING import com.jerboa.ui.theme.SMALL_PADDING @@ -123,26 +112,15 @@ fun CommentMentionNodeFooterLine( var showMoreOptions by remember { mutableStateOf(false) } if (showMoreOptions) { - CommentReplyNodeOptionsDialog( + CommentMentionsOptionsDropdown( personMentionView = personMentionView, onDismissRequest = { showMoreOptions = false }, - onPersonClick = { - showMoreOptions = false - onPersonClick(personMentionView.creator.id) - }, - onViewSourceClick = { - showMoreOptions = false - onViewSourceClick() - }, - onReportClick = { - showMoreOptions = false - onReportClick(personMentionView) - }, - onBlockCreatorClick = { - showMoreOptions = false - onBlockCreatorClick(personMentionView.creator) - }, + onPersonClick = onPersonClick, + onViewSourceClick = onViewSourceClick, + onReportClick = onReportClick, + onBlockCreatorClick = onBlockCreatorClick, isCreator = account.id == personMentionView.creator.id, + onCommentLinkClick = onLinkClick, viewSource = viewSource, ) } @@ -238,88 +216,6 @@ fun CommentMentionNodeFooterLine( } } -@Composable -fun CommentReplyNodeOptionsDialog( - personMentionView: PersonMentionView, - onDismissRequest: () -> Unit, - onPersonClick: () -> Unit, - onViewSourceClick: () -> Unit, - onReportClick: () -> Unit, - onBlockCreatorClick: () -> Unit, - isCreator: Boolean, - viewSource: Boolean, -) { - val localClipboardManager = LocalClipboardManager.current - val ctx = LocalContext.current - - AlertDialog( - onDismissRequest = onDismissRequest, - text = { - Column { - IconAndTextDrawerItem( - text = stringResource( - R.string.comment_mention_node_go_to, - personMentionView.creator.name, - ), - icon = Icons.Outlined.Person, - onClick = onPersonClick, - ) - IconAndTextDrawerItem( - text = if (viewSource) { - stringResource(R.string.comment_node_view_original) - } else { - stringResource(R.string.comment_mention_node_view_source) - }, - icon = Icons.Outlined.Description, - onClick = onViewSourceClick, - ) - IconAndTextDrawerItem( - text = stringResource(R.string.comment_mention_node_copy_permalink), - icon = Icons.Outlined.Link, - onClick = { - val permalink = personMentionView.comment.ap_id - localClipboardManager.setText(AnnotatedString(permalink)) - Toast.makeText( - ctx, - ctx.getString(R.string.comment_mention_node_permalink_copied), - Toast.LENGTH_SHORT, - ).show() - onDismissRequest() - }, - ) - IconAndTextDrawerItem( - text = stringResource(R.string.comment_mention_node_copy_comment), - icon = Icons.Outlined.ContentCopy, - onClick = { - if (copyToClipboard(ctx, personMentionView.comment.content, "comment")) { - Toast.makeText(ctx, ctx.getString(R.string.comment_mention_node_comment_copied), Toast.LENGTH_SHORT).show() - } else { - Toast.makeText(ctx, ctx.getString(R.string.generic_error), Toast.LENGTH_SHORT).show() - } - onDismissRequest() - }, - ) - if (!isCreator) { - IconAndTextDrawerItem( - text = stringResource(R.string.comment_mention_node_report_comment), - icon = Icons.Outlined.Flag, - onClick = onReportClick, - ) - IconAndTextDrawerItem( - text = stringResource( - R.string.comment_mention_node_block, - personMentionView.creator.name, - ), - icon = Icons.Outlined.Block, - onClick = onBlockCreatorClick, - ) - } - } - }, - confirmButton = {}, - ) -} - @Composable fun CommentMentionNode( personMentionView: PersonMentionView, diff --git a/app/src/main/java/com/jerboa/ui/components/comment/mentionnode/CommentMentionOptionsDropdown.kt b/app/src/main/java/com/jerboa/ui/components/comment/mentionnode/CommentMentionOptionsDropdown.kt new file mode 100644 index 000000000..4fe279443 --- /dev/null +++ b/app/src/main/java/com/jerboa/ui/components/comment/mentionnode/CommentMentionOptionsDropdown.kt @@ -0,0 +1,130 @@ +package com.jerboa.ui.components.comment.mentionnode + +import android.widget.Toast +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.Comment +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.CopyAll +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Flag +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material3.Divider +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import com.jerboa.R +import com.jerboa.copyToClipboard +import com.jerboa.datatypes.types.Person +import com.jerboa.datatypes.types.PersonId +import com.jerboa.datatypes.types.PersonMentionView +import com.jerboa.ui.components.common.PopupMenuItem +import com.jerboa.util.cascade.CascadeCenteredDropdownMenu + +@Composable +fun CommentMentionsOptionsDropdown( + personMentionView: PersonMentionView, + onDismissRequest: () -> Unit, + onCommentLinkClick: (PersonMentionView) -> Unit, + onPersonClick: (PersonId) -> Unit, + onViewSourceClick: () -> Unit, + onBlockCreatorClick: (Person) -> Unit, + onReportClick: (PersonMentionView) -> Unit, + isCreator: Boolean, + viewSource: Boolean, +) { + val localClipboardManager = LocalClipboardManager.current + val ctx = LocalContext.current + + CascadeCenteredDropdownMenu( + expanded = true, + onDismissRequest = onDismissRequest, + ) { + PopupMenuItem( + text = stringResource(R.string.comment_node_goto_comment), + icon = Icons.Outlined.Comment, + onClick = { + onDismissRequest() + onCommentLinkClick(personMentionView) + }, + ) + + PopupMenuItem( + text = stringResource(R.string.comment_node_go_to, personMentionView.creator.name), + icon = Icons.Outlined.Person, + onClick = { + onDismissRequest() + onPersonClick(personMentionView.creator.id) + }, + ) + + PopupMenuItem( + text = stringResource(R.string.copy), + icon = Icons.Outlined.CopyAll, + ) { + PopupMenuItem( + text = stringResource(R.string.comment_node_copy_permalink), + icon = Icons.Outlined.Link, + onClick = { + onDismissRequest() + val permalink = personMentionView.comment.ap_id + localClipboardManager.setText(AnnotatedString(permalink)) + Toast.makeText( + ctx, + ctx.getString(R.string.comment_node_permalink_copied), + Toast.LENGTH_SHORT, + ).show() + }, + ) + PopupMenuItem( + text = stringResource(R.string.comment_node_copy_comment), + icon = Icons.Outlined.ContentCopy, + onClick = { + onDismissRequest() + if (copyToClipboard(ctx, personMentionView.comment.content, "comment")) { + Toast.makeText(ctx, ctx.getString(R.string.comment_node_comment_copied), Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(ctx, ctx.getString(R.string.generic_error), Toast.LENGTH_SHORT).show() + } + }, + ) + } + + PopupMenuItem( + text = if (viewSource) { + stringResource(R.string.comment_node_view_original) + } else { + stringResource(R.string.comment_node_view_source) + }, + icon = Icons.Outlined.Description, + onClick = { + onDismissRequest() + onViewSourceClick() + }, + ) + + if (!isCreator) { + Divider() + PopupMenuItem( + text = stringResource(R.string.comment_node_block, personMentionView.creator.name), + icon = Icons.Outlined.Block, + onClick = { + onDismissRequest() + onBlockCreatorClick(personMentionView.creator) + }, + ) + + PopupMenuItem( + text = stringResource(R.string.comment_node_report_comment), + icon = Icons.Outlined.Flag, + onClick = { + onDismissRequest() + onReportClick(personMentionView) + }, + ) + } + } +} diff --git a/app/src/main/java/com/jerboa/ui/components/comment/replynode/CommentReplyNode.kt b/app/src/main/java/com/jerboa/ui/components/comment/replynode/CommentReplyNode.kt index 99908a818..36dc9a4ac 100644 --- a/app/src/main/java/com/jerboa/ui/components/comment/replynode/CommentReplyNode.kt +++ b/app/src/main/java/com/jerboa/ui/components/comment/replynode/CommentReplyNode.kt @@ -1,6 +1,5 @@ package com.jerboa.ui.components.comment.replynode -import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically @@ -11,17 +10,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.Comment -import androidx.compose.material.icons.outlined.Description -import androidx.compose.material.icons.outlined.Flag import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.MarkChatRead import androidx.compose.material.icons.outlined.MarkChatUnread import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Person -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Divider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -30,10 +25,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import com.jerboa.R import com.jerboa.VoteType @@ -46,7 +38,6 @@ import com.jerboa.ui.components.comment.CommentBody import com.jerboa.ui.components.comment.PostAndCommunityContextHeader import com.jerboa.ui.components.common.ActionBarButton import com.jerboa.ui.components.common.CommentOrPostNodeHeader -import com.jerboa.ui.components.common.IconAndTextDrawerItem import com.jerboa.ui.components.common.VoteGeneric import com.jerboa.ui.theme.LARGE_PADDING import com.jerboa.ui.theme.SMALL_PADDING @@ -121,26 +112,15 @@ fun CommentReplyNodeInboxFooterLine( var showMoreOptions by remember { mutableStateOf(false) } if (showMoreOptions) { - CommentReplyNodeOptionsDialog( + CommentReplyOptionsDropdown( commentReplyView = commentReplyView, onDismissRequest = { showMoreOptions = false }, - onPersonClick = { - showMoreOptions = false - onPersonClick(commentReplyView.creator.id) - }, - onViewSourceClick = { - showMoreOptions = false - onViewSourceClick() - }, - onReportClick = { - showMoreOptions = false - onReportClick(commentReplyView) - }, - onBlockCreatorClick = { - showMoreOptions = false - onBlockCreatorClick(commentReplyView.creator) - }, + onPersonClick = onPersonClick, + onViewSourceClick = onViewSourceClick, + onReportClick = onReportClick, + onBlockCreatorClick = onBlockCreatorClick, isCreator = account.id == commentReplyView.creator.id, + onCommentLinkClick = onCommentLinkClick, viewSource = viewSource, ) } @@ -234,76 +214,6 @@ fun CommentReplyNodeInboxFooterLine( } } -@Composable -fun CommentReplyNodeOptionsDialog( - commentReplyView: CommentReplyView, - onDismissRequest: () -> Unit, - onPersonClick: () -> Unit, - onViewSourceClick: () -> Unit, - onReportClick: () -> Unit, - onBlockCreatorClick: () -> Unit, - isCreator: Boolean, - viewSource: Boolean, -) { - val localClipboardManager = LocalClipboardManager.current - val ctx = LocalContext.current - - AlertDialog( - onDismissRequest = onDismissRequest, - text = { - Column { - IconAndTextDrawerItem( - text = stringResource( - R.string.comment_reply_node_go_to, - commentReplyView.creator.name, - ), - icon = Icons.Outlined.Person, - onClick = onPersonClick, - ) - IconAndTextDrawerItem( - text = if (viewSource) { - stringResource(R.string.comment_node_view_original) - } else { - stringResource(R.string.comment_reply_node_view_source) - }, - icon = Icons.Outlined.Description, - onClick = onViewSourceClick, - ) - IconAndTextDrawerItem( - text = stringResource(R.string.comment_reply_node_copy_permalink), - icon = Icons.Outlined.Link, - onClick = { - val permalink = commentReplyView.comment.ap_id - localClipboardManager.setText(AnnotatedString(permalink)) - Toast.makeText( - ctx, - ctx.getString(R.string.comment_reply_node_permalink_copied), - Toast.LENGTH_SHORT, - ).show() - onDismissRequest() - }, - ) - if (!isCreator) { - IconAndTextDrawerItem( - text = stringResource(R.string.comment_reply_node_report_comment), - icon = Icons.Outlined.Flag, - onClick = onReportClick, - ) - IconAndTextDrawerItem( - text = stringResource( - R.string.comment_reply_node_block, - commentReplyView.creator.name, - ), - icon = Icons.Outlined.Block, - onClick = onBlockCreatorClick, - ) - } - } - }, - confirmButton = {}, - ) -} - @Composable fun CommentReplyNodeInbox( commentReplyView: CommentReplyView, diff --git a/app/src/main/java/com/jerboa/ui/components/comment/replynode/CommentReplyOptionsDropdown.kt b/app/src/main/java/com/jerboa/ui/components/comment/replynode/CommentReplyOptionsDropdown.kt new file mode 100644 index 000000000..d3d623547 --- /dev/null +++ b/app/src/main/java/com/jerboa/ui/components/comment/replynode/CommentReplyOptionsDropdown.kt @@ -0,0 +1,129 @@ +package com.jerboa.ui.components.comment.replynode + +import android.widget.Toast +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.Comment +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.CopyAll +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Flag +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material3.Divider +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import com.jerboa.R +import com.jerboa.copyToClipboard +import com.jerboa.datatypes.types.CommentReplyView +import com.jerboa.datatypes.types.Person +import com.jerboa.datatypes.types.PersonId +import com.jerboa.ui.components.common.PopupMenuItem +import com.jerboa.util.cascade.CascadeCenteredDropdownMenu + +@Composable +fun CommentReplyOptionsDropdown( + commentReplyView: CommentReplyView, + onDismissRequest: () -> Unit, + onCommentLinkClick: (CommentReplyView) -> Unit, + onPersonClick: (PersonId) -> Unit, + onViewSourceClick: () -> Unit, + onBlockCreatorClick: (Person) -> Unit, + onReportClick: (CommentReplyView) -> Unit, + isCreator: Boolean, + viewSource: Boolean, +) { + val localClipboardManager = LocalClipboardManager.current + val ctx = LocalContext.current + + CascadeCenteredDropdownMenu( + expanded = true, + onDismissRequest = onDismissRequest, + ) { + PopupMenuItem( + text = stringResource(R.string.comment_node_goto_comment), + icon = Icons.Outlined.Comment, + onClick = { + onDismissRequest() + onCommentLinkClick(commentReplyView) + }, + ) + + PopupMenuItem( + text = stringResource(R.string.comment_node_go_to, commentReplyView.creator.name), + icon = Icons.Outlined.Person, + onClick = { + onDismissRequest() + onPersonClick(commentReplyView.creator.id) + }, + ) + + PopupMenuItem( + text = stringResource(R.string.copy), + icon = Icons.Outlined.CopyAll, + ) { + PopupMenuItem( + text = stringResource(R.string.comment_node_copy_permalink), + icon = Icons.Outlined.Link, + onClick = { + onDismissRequest() + val permalink = commentReplyView.comment.ap_id + localClipboardManager.setText(AnnotatedString(permalink)) + Toast.makeText( + ctx, + ctx.getString(R.string.comment_node_permalink_copied), + Toast.LENGTH_SHORT, + ).show() + }, + ) + PopupMenuItem( + text = stringResource(R.string.comment_node_copy_comment), + icon = Icons.Outlined.ContentCopy, + onClick = { + onDismissRequest() + if (copyToClipboard(ctx, commentReplyView.comment.content, "comment")) { + Toast.makeText(ctx, ctx.getString(R.string.comment_node_comment_copied), Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(ctx, ctx.getString(R.string.generic_error), Toast.LENGTH_SHORT).show() + } + }, + ) + } + + PopupMenuItem( + text = if (viewSource) { + stringResource(R.string.comment_node_view_original) + } else { + stringResource(R.string.comment_node_view_source) + }, + icon = Icons.Outlined.Description, + onClick = { + onDismissRequest() + onViewSourceClick() + }, + ) + + if (!isCreator) { + Divider() + PopupMenuItem( + text = stringResource(R.string.comment_node_block, commentReplyView.creator.name), + icon = Icons.Outlined.Block, + onClick = { + onDismissRequest() + onBlockCreatorClick(commentReplyView.creator) + }, + ) + PopupMenuItem( + text = stringResource(R.string.comment_node_report_comment), + icon = Icons.Outlined.Flag, + onClick = { + onDismissRequest() + onReportClick(commentReplyView) + }, + ) + } + } +} diff --git a/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt b/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt index 521cd38d0..6d632fc99 100644 --- a/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt +++ b/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt @@ -5,8 +5,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Text @@ -20,16 +18,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.compose.ui.tooling.preview.Preview import com.jerboa.R import com.jerboa.api.MINIMUM_API_VERSION -import com.jerboa.datatypes.types.CommentSortType -import com.jerboa.datatypes.types.SortType import com.jerboa.model.AppSettingsViewModel val DONATION_MARKDOWN = """ @@ -44,116 +38,6 @@ val DONATION_MARKDOWN = """ """.trimIndent() -val isTopSort = { sort: SortType -> sort.name.startsWith("Top") } - -@Composable -fun SortTopOptionsDialog( - onDismissRequest: () -> Unit, - onClickSortType: (SortType) -> Unit, - selectedSortType: SortType, - siteVersion: String, -) { - val ctx = LocalContext.current - AlertDialog( - onDismissRequest = onDismissRequest, - text = { - Column { - SortType.getSupportedSortTypes(siteVersion).filter(isTopSort).forEach { - IconAndTextDrawerItem( - text = ctx.getString(it.longForm), - onClick = { onClickSortType(it) }, - highlight = (selectedSortType == it), - ) - } - } - }, - confirmButton = {}, - ) -} - -@Preview -@Composable -fun SortOptionsDialogPreview() { - SortOptionsDialog( - selectedSortType = SortType.Hot, - onDismissRequest = {}, - onClickSortTopOptions = {}, - onClickSortType = {}, - siteVersion = MINIMUM_API_VERSION, - ) -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun SortOptionsDialog( - onDismissRequest: () -> Unit, - onClickSortType: (SortType) -> Unit, - onClickSortTopOptions: () -> Unit, - selectedSortType: SortType, - siteVersion: String, -) { - AlertDialog( - modifier = Modifier.semantics { testTagsAsResourceId = true }, - onDismissRequest = onDismissRequest, - text = { - Column { - SortType.getSupportedSortTypes(siteVersion).filter { !isTopSort(it) }.forEach { - IconAndTextDrawerItem( - text = stringResource(it.longForm), - icon = it.icon, - onClick = { onClickSortType(it) }, - highlight = (selectedSortType == it), - ) - } - IconAndTextDrawerItem( - text = stringResource(R.string.dialogs_top), - icon = Icons.Outlined.BarChart, - onClick = onClickSortTopOptions, - more = true, - highlight = (isTopSort(selectedSortType)), - ) - } - }, - confirmButton = {}, - ) -} - -@Preview -@Composable -fun CommentSortOptionsDialogPreview() { - CommentSortOptionsDialog( - selectedSortType = CommentSortType.Hot, - onDismissRequest = {}, - onClickSortType = {}, - siteVersion = MINIMUM_API_VERSION, - ) -} - -@Composable -fun CommentSortOptionsDialog( - onDismissRequest: () -> Unit, - onClickSortType: (CommentSortType) -> Unit, - selectedSortType: CommentSortType, - siteVersion: String, -) { - AlertDialog( - onDismissRequest = onDismissRequest, - text = { - Column { - CommentSortType.getSupportedSortTypes(siteVersion).forEach { - IconAndTextDrawerItem( - text = stringResource(it.text), - icon = it.icon, - onClick = { onClickSortType(it) }, - highlight = (selectedSortType == it), - ) - } - } - }, - confirmButton = {}, - ) -} - @OptIn(ExperimentalComposeUiApi::class) @Composable fun ShowChangelog(appSettingsViewModel: AppSettingsViewModel) { diff --git a/app/src/main/java/com/jerboa/ui/components/common/DropdownMenu.kt b/app/src/main/java/com/jerboa/ui/components/common/DropdownMenu.kt index d440fe237..e9418afd9 100644 --- a/app/src/main/java/com/jerboa/ui/components/common/DropdownMenu.kt +++ b/app/src/main/java/com/jerboa/ui/components/common/DropdownMenu.kt @@ -1,14 +1,121 @@ package com.jerboa.ui.components.common import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.jerboa.R +import com.jerboa.datatypes.types.CommentSortType +import com.jerboa.datatypes.types.SortType +import com.jerboa.ui.theme.LARGE_PADDING +import com.jerboa.ui.theme.Shapes +import com.jerboa.util.cascade.CascadeColumnScope +import com.jerboa.util.cascade.CascadeDropdownMenu + +val isTopSort = { sort: SortType -> sort.name.startsWith("Top") } + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun SortOptionsDropdown( + expanded: Boolean, + onDismissRequest: () -> Unit, + siteVersion: String, + onClickSortType: (SortType) -> Unit, + selectedSortType: SortType, + +) { + CascadeDropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = Modifier.semantics { testTagsAsResourceId = true }, + ) { + SortType.getSupportedSortTypes(siteVersion).filter { !isTopSort(it) }.forEach { + DropdownMenuItem( + text = { Text(stringResource(it.longForm)) }, + leadingIcon = { Icon(it.icon, contentDescription = null) }, + onClick = { onClickSortType(it) }, + modifier = Modifier.ifDo(selectedSortType == it) { + this.background(MaterialTheme.colorScheme.onBackground.copy(alpha = .1f)) + }, + ) + } + + DropdownMenuItem( + text = { Text(stringResource(R.string.dialogs_top)) }, + leadingIcon = { Icon(Icons.Outlined.BarChart, contentDescription = null) }, + modifier = if (isTopSort(selectedSortType)) { + Modifier.background(MaterialTheme.colorScheme.onBackground.copy(alpha = .1f)) + } else { + Modifier + }, + children = { + SortType.getSupportedSortTypes(siteVersion).filter(isTopSort).forEach { + DropdownMenuItem( + text = { Text(stringResource(it.longForm)) }, + onClick = { + onDismissRequest() + onClickSortType(it) + }, + modifier = Modifier.ifDo(selectedSortType == it) { + this.background(MaterialTheme.colorScheme.onBackground.copy(alpha = .1f)) + }, + ) + } + }, + + ) + } +} + +@Composable +fun CommentSortOptionsDropdown( + expanded: Boolean, + onDismissRequest: () -> Unit, + onClickSortType: (CommentSortType) -> Unit, + selectedSortType: CommentSortType, + siteVersion: String, +) { + CascadeDropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + ) { + CommentSortType.getSupportedSortTypes(siteVersion).forEach { + DropdownMenuItem( + text = { Text(stringResource(it.text)) }, + leadingIcon = { Icon(imageVector = it.icon, contentDescription = null) }, + onClick = { + onDismissRequest() + onClickSortType(it) + }, + modifier = Modifier.ifDo(selectedSortType == it) { + this.background(MaterialTheme.colorScheme.onBackground.copy(alpha = .1f)) + }, + + ) + } + } +} @Composable fun MenuItem( @@ -59,3 +166,89 @@ fun MenuItem( }, ) } + +@Composable +fun PopupMenuItem( + text: String, + onClick: () -> Unit, + icon: ImageVector, + modifier: Modifier = Modifier, + textModifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.bodyLarge, +) { + DropdownMenuItem( + text = { + Text( + text = text, + style = textStyle, + modifier = textModifier.padding(start = LARGE_PADDING), + ) + }, + leadingIcon = { + Icon( + imageVector = icon, + contentDescription = text, + ) + }, + onClick = onClick, + modifier = modifier, + ) +} + +@Composable +fun CascadeColumnScope.PopupMenuItem( + text: String, + icon: ImageVector, + modifier: Modifier = Modifier, + textModifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.bodyLarge, + children: @Composable CascadeColumnScope.() -> Unit, +) { + DropdownMenuItem( + text = { + Text( + text = text, + style = textStyle, + modifier = textModifier.padding(start = LARGE_PADDING), + ) + }, + leadingIcon = { + Icon( + imageVector = icon, + contentDescription = text, + ) + }, + children = children, + modifier = modifier, + ) +} + +@Composable +fun CenteredPopupMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + tonalElevation: Dp = 3.dp, + content: @Composable ColumnScope.() -> Unit, +) { + if (expanded) { + Popup( + alignment = Alignment.Center, + onDismissRequest = onDismissRequest, + properties = PopupProperties(focusable = true), + ) { + Surface( + shape = Shapes.extraSmall, + color = MaterialTheme.colorScheme.surface, + tonalElevation = tonalElevation, + shadowElevation = 6.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth(0.86f) + .padding(vertical = LARGE_PADDING), + content = content, + ) + } + } + } +} diff --git a/app/src/main/java/com/jerboa/ui/components/common/LinkDropDownMenu.kt b/app/src/main/java/com/jerboa/ui/components/common/LinkDropDownMenu.kt index af1079ef5..6f1836a88 100644 --- a/app/src/main/java/com/jerboa/ui/components/common/LinkDropDownMenu.kt +++ b/app/src/main/java/com/jerboa/ui/components/common/LinkDropDownMenu.kt @@ -1,10 +1,6 @@ package com.jerboa.ui.components.common -import android.content.ActivityNotFoundException -import android.util.Log import android.widget.Toast -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Download @@ -14,10 +10,8 @@ import androidx.compose.material.icons.outlined.OpenInFull import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.Divider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext @@ -27,8 +21,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties import com.jerboa.JerboaAppState import com.jerboa.PostType import com.jerboa.R @@ -38,7 +30,6 @@ import com.jerboa.feat.storeMedia import com.jerboa.isMedia import com.jerboa.rememberJerboaAppState import com.jerboa.ui.theme.LARGE_PADDING -import com.jerboa.ui.theme.Shapes @Composable fun LinkDropDownMenu( @@ -54,164 +45,125 @@ fun LinkDropDownMenu( if (link != null) { val mediaType = PostType.fromURL(link) - Popup( - alignment = Alignment.Center, + CenteredPopupMenu( + expanded = true, onDismissRequest = onDismissRequest, - properties = PopupProperties(focusable = true), + tonalElevation = 6.dp, ) { - Surface( - shape = Shapes.extraSmall, - color = MaterialTheme.colorScheme.surface, - tonalElevation = 6.dp, - shadowElevation = 6.dp, - ) { - Column( - modifier = Modifier - .fillMaxWidth(0.86f) - .padding(vertical = LARGE_PADDING), - ) { - Text( - text = link, - textDecoration = TextDecoration.Underline, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(LARGE_PADDING), - color = MaterialTheme.colorScheme.tertiary, - ) + Text( + text = link, + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(LARGE_PADDING), + color = MaterialTheme.colorScheme.tertiary, + ) + + PopupMenuItem( + text = stringResource(R.string.open_link), + icon = Icons.Outlined.OpenInFull, + onClick = { + onDismissRequest() + if (mediaType == PostType.Image) { + appState.openImageViewer(link) + } else { + appState.openLink(link, useCustomTabs, usePrivateTabs) + } + }, + ) + PopupMenuItem( + text = stringResource(R.string.open_link_external), + icon = Icons.Outlined.OpenInBrowser, + onClick = { + onDismissRequest() + appState.openLinkRaw(link, useCustomTabs, usePrivateTabs) + }, + ) + + Divider() + + PopupMenuItem( + text = stringResource(R.string.post_listing_copy_link), + icon = Icons.Outlined.Link, + onClick = { + onDismissRequest() + localClipboardManager.setText(AnnotatedString(link)) + Toast.makeText( + ctx, + ctx.getString(R.string.post_listing_link_copied), + Toast.LENGTH_SHORT, + ).show() + }, + ) - MenuItem( - text = stringResource(R.string.open_link), - icon = Icons.Outlined.OpenInFull, + PopupMenuItem( + text = stringResource(R.string.post_listing_share_link), + icon = Icons.Outlined.Share, + onClick = { + onDismissRequest() + shareLink(link, ctx) + }, + ) + + when (mediaType) { + PostType.Image -> { + Divider() + PopupMenuItem( + text = stringResource(R.string.share_image), + icon = Icons.Outlined.Share, onClick = { - if (mediaType == PostType.Image) { - appState.openImageViewer(link) - } else { - appState.openLink(link, useCustomTabs, usePrivateTabs) - } onDismissRequest() + shareMedia(appState.coroutineScope, ctx, link, mediaType) }, - textStyle = MaterialTheme.typography.bodyLarge, - textModifier = Modifier.padding(start = LARGE_PADDING), ) - MenuItem( - text = stringResource(R.string.open_link_external), - icon = Icons.Outlined.OpenInBrowser, + PopupMenuItem( + text = stringResource(R.string.save_image), + icon = Icons.Outlined.Download, onClick = { - appState.openLinkRaw(link, useCustomTabs, usePrivateTabs) - - try { - } catch (e: ActivityNotFoundException) { - Log.d("jerboa", "failed open activity", e) - Toast.makeText(ctx, ctx.getText(R.string.no_activity_found), Toast.LENGTH_SHORT).show() - } - onDismissRequest() + storeMedia(appState.coroutineScope, ctx, link, mediaType) }, - textStyle = MaterialTheme.typography.bodyLarge, - textModifier = Modifier.padding(start = LARGE_PADDING), ) - Divider() + } - MenuItem( - text = stringResource(R.string.post_listing_copy_link), - icon = Icons.Outlined.Link, + PostType.Video -> { + Divider() + PopupMenuItem( + text = stringResource(R.string.share_video), + icon = Icons.Outlined.Share, onClick = { - localClipboardManager.setText(AnnotatedString(link)) - Toast.makeText( - ctx, - ctx.getString(R.string.post_listing_link_copied), - Toast.LENGTH_SHORT, - ).show() onDismissRequest() + shareMedia(appState.coroutineScope, ctx, link, mediaType) }, - textStyle = MaterialTheme.typography.bodyLarge, - textModifier = Modifier.padding(start = LARGE_PADDING), ) - - MenuItem( - text = stringResource(R.string.post_listing_share_link), - icon = Icons.Outlined.Share, + PopupMenuItem( + text = stringResource(R.string.save_video), + icon = Icons.Outlined.Download, onClick = { - shareLink(link, ctx) onDismissRequest() + storeMedia(appState.coroutineScope, ctx, link, mediaType) }, - textStyle = MaterialTheme.typography.bodyLarge, - textModifier = Modifier.padding(start = LARGE_PADDING), ) + } - when (mediaType) { - PostType.Image -> { - Divider() - MenuItem( - text = stringResource(R.string.share_image), - icon = Icons.Outlined.Share, - onClick = { - shareMedia(appState.coroutineScope, ctx, link, mediaType) - onDismissRequest() - }, - textStyle = MaterialTheme.typography.bodyLarge, - textModifier = Modifier.padding(start = LARGE_PADDING), - ) - MenuItem( - text = stringResource(R.string.save_image), - icon = Icons.Outlined.Download, - onClick = { - storeMedia(appState.coroutineScope, ctx, link, mediaType) - onDismissRequest() - }, - textStyle = MaterialTheme.typography.bodyLarge, - textModifier = Modifier.padding(start = LARGE_PADDING), - ) - } - - PostType.Video -> { - Divider() - MenuItem( - text = stringResource(R.string.share_video), - icon = Icons.Outlined.Share, - onClick = { - shareMedia(appState.coroutineScope, ctx, link, mediaType) - onDismissRequest() - }, - textStyle = MaterialTheme.typography.bodyLarge, - textModifier = Modifier.padding(start = LARGE_PADDING), - ) - MenuItem( - text = stringResource(R.string.save_video), - icon = Icons.Outlined.Download, - onClick = { - storeMedia(appState.coroutineScope, ctx, link, mediaType) - onDismissRequest() - }, - textStyle = MaterialTheme.typography.bodyLarge, - textModifier = Modifier.padding(start = LARGE_PADDING), - ) - } - - PostType.Link -> { - if (isMedia(link)) { - Divider() - MenuItem( - text = stringResource(R.string.share), - icon = Icons.Outlined.Share, - onClick = { - shareMedia(appState.coroutineScope, ctx, link, PostType.Link) - onDismissRequest() - }, - textStyle = MaterialTheme.typography.bodyLarge, - textModifier = Modifier.padding(start = LARGE_PADDING), - ) - MenuItem( - text = stringResource(R.string.save), - icon = Icons.Outlined.Download, - onClick = { - storeMedia(appState.coroutineScope, ctx, link, PostType.Link) - onDismissRequest() - }, - textStyle = MaterialTheme.typography.bodyLarge, - textModifier = Modifier.padding(start = LARGE_PADDING), - ) - } - } + PostType.Link -> { + if (isMedia(link)) { + Divider() + PopupMenuItem( + text = stringResource(R.string.share_media), + icon = Icons.Outlined.Share, + onClick = { + onDismissRequest() + shareMedia(appState.coroutineScope, ctx, link, PostType.Link) + }, + ) + PopupMenuItem( + text = stringResource(R.string.save), + icon = Icons.Outlined.Download, + onClick = { + onDismissRequest() + storeMedia(appState.coroutineScope, ctx, link, PostType.Link) + }, + ) } } } diff --git a/app/src/main/java/com/jerboa/ui/components/common/Modifiers.kt b/app/src/main/java/com/jerboa/ui/components/common/Modifiers.kt new file mode 100644 index 000000000..73024a3b6 --- /dev/null +++ b/app/src/main/java/com/jerboa/ui/components/common/Modifiers.kt @@ -0,0 +1,116 @@ +package com.jerboa.ui.components.common + +import android.os.Build +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.TabPosition +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.Autofill +import androidx.compose.ui.autofill.AutofillNode +import androidx.compose.ui.autofill.AutofillTree +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import kotlinx.collections.immutable.ImmutableList + +inline fun Modifier.ifDo(predicate: Boolean, modifier: Modifier.() -> Modifier): Modifier { + return if (predicate) modifier() else this +} + +fun Modifier.getBlurredOrRounded( + blur: Boolean, + rounded: Boolean = false, +): Modifier { + var lModifier = this + + if (rounded) { + lModifier = lModifier.clip(RoundedCornerShape(12f)) + } + if (blur && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + lModifier = lModifier.blur(radius = 100.dp) + } + return lModifier +} + +@OptIn(ExperimentalComposeUiApi::class) +fun Modifier.onAutofill( + tree: AutofillTree, + autofill: Autofill?, + autofillTypes: ImmutableList, + onFill: (String) -> Unit, +): Modifier { + val autofillNode = AutofillNode( + autofillTypes = autofillTypes, + onFill = onFill, + ) + tree += autofillNode + + return this + .onGloballyPositioned { + autofillNode.boundingBox = it.boundsInWindow() + } + .onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNode) + } else { + cancelAutofillForNode(autofillNode) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +fun Modifier.pagerTabIndicatorOffset2( + pagerState: PagerState, + tabPositions: List, + pageIndexMapping: (Int) -> Int = { it }, +): Modifier = layout { measurable, constraints -> + if (tabPositions.isEmpty()) { + // If there are no pages, nothing to show + layout(constraints.maxWidth, 0) {} + } else { + val currentPage = minOf(tabPositions.lastIndex, pageIndexMapping(pagerState.currentPage)) + val currentTab = tabPositions[currentPage] + val previousTab = tabPositions.getOrNull(currentPage - 1) + val nextTab = tabPositions.getOrNull(currentPage + 1) + val fraction = pagerState.currentPageOffsetFraction + val indicatorWidth = if (fraction > 0 && nextTab != null) { + lerp(currentTab.width, nextTab.width, fraction).roundToPx() + } else if (fraction < 0 && previousTab != null) { + lerp(currentTab.width, previousTab.width, -fraction).roundToPx() + } else { + currentTab.width.roundToPx() + } + val indicatorOffset = if (fraction > 0 && nextTab != null) { + lerp(currentTab.left, nextTab.left, fraction).roundToPx() + } else if (fraction < 0 && previousTab != null) { + lerp(currentTab.left, previousTab.left, -fraction).roundToPx() + } else { + currentTab.left.roundToPx() + } + val placeable = measurable.measure( + Constraints( + minWidth = indicatorWidth, + maxWidth = indicatorWidth, + minHeight = 0, + maxHeight = constraints.maxHeight, + ), + ) + layout(constraints.maxWidth, maxOf(placeable.height, constraints.minHeight)) { + placeable.placeRelative( + indicatorOffset, + maxOf(constraints.minHeight - placeable.height, 0), + ) + } + } +} diff --git a/app/src/main/java/com/jerboa/ui/components/common/PictrsImage.kt b/app/src/main/java/com/jerboa/ui/components/common/PictrsImage.kt index 6d59a5b8a..83f57f4db 100644 --- a/app/src/main/java/com/jerboa/ui/components/common/PictrsImage.kt +++ b/app/src/main/java/com/jerboa/ui/components/common/PictrsImage.kt @@ -28,7 +28,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import com.jerboa.R @@ -102,21 +101,6 @@ fun CircularIconPreview() { ) } -fun Modifier.getBlurredOrRounded( - blur: Boolean, - rounded: Boolean = false, -): Modifier { - var lModifier = this - - if (rounded) { - lModifier = lModifier.clip(RoundedCornerShape(12f)) - } - if (blur && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - lModifier = lModifier.blur(radius = 100.dp) - } - return lModifier -} - fun getImageRequest( context: Context, path: String, diff --git a/app/src/main/java/com/jerboa/ui/components/community/Community.kt b/app/src/main/java/com/jerboa/ui/components/community/Community.kt index a780c2204..1d9bb48a4 100644 --- a/app/src/main/java/com/jerboa/ui/components/community/Community.kt +++ b/app/src/main/java/com/jerboa/ui/components/community/Community.kt @@ -22,8 +22,7 @@ import com.jerboa.datatypes.types.SortType import com.jerboa.datatypes.types.SubscribedType import com.jerboa.ui.components.common.LargerCircularIcon import com.jerboa.ui.components.common.PictrsBannerImage -import com.jerboa.ui.components.common.SortOptionsDialog -import com.jerboa.ui.components.common.SortTopOptionsDialog +import com.jerboa.ui.components.common.SortOptionsDropdown import com.jerboa.ui.theme.* import com.jerboa.util.cascade.CascadeDropdownMenu @@ -141,37 +140,8 @@ fun CommunityHeader( isBlocked: Boolean, ) { var showSortOptions by remember { mutableStateOf(false) } - var showTopOptions by remember { mutableStateOf(false) } var showMoreOptions by remember { mutableStateOf(false) } - if (showSortOptions) { - SortOptionsDialog( - selectedSortType = selectedSortType, - onDismissRequest = { showSortOptions = false }, - onClickSortType = { - showSortOptions = false - onClickSortType(it) - }, - onClickSortTopOptions = { - showSortOptions = false - showTopOptions = !showTopOptions - }, - siteVersion = siteVersion, - ) - } - - if (showTopOptions) { - SortTopOptionsDialog( - selectedSortType = selectedSortType, - onDismissRequest = { showTopOptions = false }, - onClickSortType = { - showTopOptions = false - onClickSortType(it) - }, - siteVersion = siteVersion, - ) - } - TopAppBar( scrollBehavior = scrollBehavior, title = { @@ -189,14 +159,28 @@ fun CommunityHeader( } }, actions = { - IconButton(onClick = { - showSortOptions = !showSortOptions - }) { - Icon( - Icons.Outlined.Sort, - contentDescription = stringResource(R.string.community_sortBy), + Box { + IconButton(onClick = { + showSortOptions = !showSortOptions + }) { + Icon( + Icons.Outlined.Sort, + contentDescription = stringResource(R.string.community_sortBy), + ) + } + + SortOptionsDropdown( + expanded = showSortOptions, + onDismissRequest = { showSortOptions = false }, + onClickSortType = { + showSortOptions = false + onClickSortType(it) + }, + selectedSortType = selectedSortType, + siteVersion = siteVersion, ) } + Box { IconButton(onClick = { showMoreOptions = !showMoreOptions diff --git a/app/src/main/java/com/jerboa/ui/components/home/Home.kt b/app/src/main/java/com/jerboa/ui/components/home/Home.kt index 456b76df6..bec376878 100644 --- a/app/src/main/java/com/jerboa/ui/components/home/Home.kt +++ b/app/src/main/java/com/jerboa/ui/components/home/Home.kt @@ -47,8 +47,7 @@ import com.jerboa.datatypes.types.Tagline import com.jerboa.getLocalizedListingTypeName import com.jerboa.ui.components.common.MenuItem import com.jerboa.ui.components.common.MyMarkdownText -import com.jerboa.ui.components.common.SortOptionsDialog -import com.jerboa.ui.components.common.SortTopOptionsDialog +import com.jerboa.ui.components.common.SortOptionsDropdown import com.jerboa.ui.theme.LARGE_PADDING import com.jerboa.util.cascade.CascadeDropdownMenu import kotlinx.collections.immutable.ImmutableList @@ -87,38 +86,9 @@ fun HomeHeader( siteVersion: String, ) { var showSortOptions by remember { mutableStateOf(false) } - var showTopOptions by remember { mutableStateOf(false) } var showListingTypeOptions by remember { mutableStateOf(false) } var showMoreOptions by remember { mutableStateOf(false) } - if (showSortOptions) { - SortOptionsDialog( - selectedSortType = selectedSortType, - onDismissRequest = { showSortOptions = false }, - onClickSortType = { - showSortOptions = false - onClickSortType(it) - }, - onClickSortTopOptions = { - showSortOptions = false - showTopOptions = !showTopOptions - }, - siteVersion = siteVersion, - ) - } - - if (showTopOptions) { - SortTopOptionsDialog( - selectedSortType = selectedSortType, - onDismissRequest = { showTopOptions = false }, - onClickSortType = { - showTopOptions = false - onClickSortType(it) - }, - siteVersion = siteVersion, - ) - } - TopAppBar( scrollBehavior = scrollBehavior, title = { @@ -157,12 +127,26 @@ fun HomeHeader( selectedListingType = selectedListingType, ) } - IconButton(modifier = Modifier.testTag("jerboa:sortoptions"), onClick = { - showSortOptions = !showSortOptions - }) { - Icon( - Icons.Outlined.Sort, - contentDescription = stringResource(R.string.selectSort), + + Box { + IconButton(modifier = Modifier.testTag("jerboa:sortoptions"), onClick = { + showSortOptions = !showSortOptions + }) { + Icon( + Icons.Outlined.Sort, + contentDescription = stringResource(R.string.selectSort), + ) + } + + SortOptionsDropdown( + expanded = showSortOptions, + onDismissRequest = { showSortOptions = false }, + onClickSortType = { + showSortOptions = false + onClickSortType(it) + }, + selectedSortType = selectedSortType, + siteVersion = siteVersion, ) } Box { diff --git a/app/src/main/java/com/jerboa/ui/components/inbox/InboxActivity.kt b/app/src/main/java/com/jerboa/ui/components/inbox/InboxActivity.kt index 00f9a57a3..b7aed0e8c 100644 --- a/app/src/main/java/com/jerboa/ui/components/inbox/InboxActivity.kt +++ b/app/src/main/java/com/jerboa/ui/components/inbox/InboxActivity.kt @@ -65,7 +65,6 @@ import com.jerboa.model.InboxViewModel import com.jerboa.model.ReplyItem import com.jerboa.model.SiteViewModel import com.jerboa.newVote -import com.jerboa.pagerTabIndicatorOffset2 import com.jerboa.rootChannel import com.jerboa.ui.components.comment.mentionnode.CommentMentionNode import com.jerboa.ui.components.comment.replynode.CommentReplyNodeInbox @@ -76,6 +75,7 @@ import com.jerboa.ui.components.common.LoadingBar import com.jerboa.ui.components.common.getCurrentAccount import com.jerboa.ui.components.common.isLoading import com.jerboa.ui.components.common.isRefreshing +import com.jerboa.ui.components.common.pagerTabIndicatorOffset2 import com.jerboa.ui.components.common.simpleVerticalScrollbar import com.jerboa.ui.components.privatemessage.PrivateMessage import com.jerboa.unreadOrAllFromBool diff --git a/app/src/main/java/com/jerboa/ui/components/login/Login.kt b/app/src/main/java/com/jerboa/ui/components/login/Login.kt index 90e34179d..6f295dd4a 100644 --- a/app/src/main/java/com/jerboa/ui/components/login/Login.kt +++ b/app/src/main/java/com/jerboa/ui/components/login/Login.kt @@ -54,7 +54,7 @@ import androidx.compose.ui.window.PopupProperties import com.jerboa.DEFAULT_LEMMY_INSTANCES import com.jerboa.R import com.jerboa.datatypes.types.Login -import com.jerboa.onAutofill +import com.jerboa.ui.components.common.onAutofill import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf diff --git a/app/src/main/java/com/jerboa/ui/components/person/PersonProfile.kt b/app/src/main/java/com/jerboa/ui/components/person/PersonProfile.kt index 33ccfacea..b01a7b4ef 100644 --- a/app/src/main/java/com/jerboa/ui/components/person/PersonProfile.kt +++ b/app/src/main/java/com/jerboa/ui/components/person/PersonProfile.kt @@ -39,8 +39,7 @@ import com.jerboa.ui.components.common.LargerCircularIcon import com.jerboa.ui.components.common.MenuItem import com.jerboa.ui.components.common.MyMarkdownText import com.jerboa.ui.components.common.PictrsBannerImage -import com.jerboa.ui.components.common.SortOptionsDialog -import com.jerboa.ui.components.common.SortTopOptionsDialog +import com.jerboa.ui.components.common.SortOptionsDropdown import com.jerboa.ui.components.common.TimeAgo import com.jerboa.ui.theme.MEDIUM_PADDING import com.jerboa.ui.theme.PROFILE_BANNER_SIZE @@ -157,37 +156,8 @@ fun PersonProfileHeader( siteVersion: String, ) { var showSortOptions by remember { mutableStateOf(false) } - var showTopOptions by remember { mutableStateOf(false) } var showMoreOptions by remember { mutableStateOf(false) } - if (showSortOptions) { - SortOptionsDialog( - selectedSortType = selectedSortType, - onDismissRequest = { showSortOptions = false }, - onClickSortType = { - showSortOptions = false - onClickSortType(it) - }, - onClickSortTopOptions = { - showSortOptions = false - showTopOptions = !showTopOptions - }, - siteVersion = siteVersion, - ) - } - - if (showTopOptions) { - SortTopOptionsDialog( - selectedSortType = selectedSortType, - onDismissRequest = { showTopOptions = false }, - onClickSortType = { - showTopOptions = false - onClickSortType(it) - }, - siteVersion = siteVersion, - ) - } - TopAppBar( scrollBehavior = scrollBehavior, title = { @@ -214,14 +184,28 @@ fun PersonProfileHeader( } }, actions = { - IconButton(onClick = { - showSortOptions = !showSortOptions - }) { - Icon( - Icons.Outlined.Sort, - contentDescription = stringResource(R.string.selectSort), + Box { + IconButton(onClick = { + showSortOptions = !showSortOptions + }) { + Icon( + Icons.Outlined.Sort, + contentDescription = stringResource(R.string.community_sortBy), + ) + } + + SortOptionsDropdown( + expanded = showSortOptions, + onDismissRequest = { showSortOptions = false }, + onClickSortType = { + showSortOptions = false + onClickSortType(it) + }, + selectedSortType = selectedSortType, + siteVersion = siteVersion, ) } + if (!myProfile && isLoggedIn()) { Box { IconButton(onClick = { diff --git a/app/src/main/java/com/jerboa/ui/components/person/PersonProfileActivity.kt b/app/src/main/java/com/jerboa/ui/components/person/PersonProfileActivity.kt index b4f883ab1..1c624f0c6 100644 --- a/app/src/main/java/com/jerboa/ui/components/person/PersonProfileActivity.kt +++ b/app/src/main/java/com/jerboa/ui/components/person/PersonProfileActivity.kt @@ -81,7 +81,6 @@ import com.jerboa.model.PersonProfileViewModel import com.jerboa.model.ReplyItem import com.jerboa.model.SiteViewModel import com.jerboa.newVote -import com.jerboa.pagerTabIndicatorOffset2 import com.jerboa.rootChannel import com.jerboa.scrollToTop import com.jerboa.ui.components.comment.CommentNodes @@ -96,6 +95,7 @@ import com.jerboa.ui.components.common.getCurrentAccount import com.jerboa.ui.components.common.getPostViewMode import com.jerboa.ui.components.common.isLoading import com.jerboa.ui.components.common.isRefreshing +import com.jerboa.ui.components.common.pagerTabIndicatorOffset2 import com.jerboa.ui.components.common.simpleVerticalScrollbar import com.jerboa.ui.components.community.CommunityLink import com.jerboa.ui.components.post.PostListings diff --git a/app/src/main/java/com/jerboa/ui/components/post/PostActivity.kt b/app/src/main/java/com/jerboa/ui/components/post/PostActivity.kt index da3117af2..4199a8964 100644 --- a/app/src/main/java/com/jerboa/ui/components/post/PostActivity.kt +++ b/app/src/main/java/com/jerboa/ui/components/post/PostActivity.kt @@ -95,7 +95,7 @@ import com.jerboa.ui.components.comment.edit.CommentEditReturn import com.jerboa.ui.components.comment.reply.CommentReplyReturn import com.jerboa.ui.components.common.ApiErrorText import com.jerboa.ui.components.common.CommentNavigationBottomAppBar -import com.jerboa.ui.components.common.CommentSortOptionsDialog +import com.jerboa.ui.components.common.CommentSortOptionsDropdown import com.jerboa.ui.components.common.JerboaSnackbarHost import com.jerboa.ui.components.common.LoadingBar import com.jerboa.ui.components.common.apiErrorToast @@ -208,18 +208,6 @@ fun PostActivity( refreshingOffset = 150.dp, ) - if (showSortOptions) { - CommentSortOptionsDialog( - selectedSortType = selectedSortType, - onDismissRequest = { showSortOptions = false }, - onClickSortType = { - showSortOptions = false - onClickSortType(it) - }, - siteVersion = siteViewModel.siteVersion(), - ) - } - LaunchedEffect(Unit) { focusRequester.requestFocus() } @@ -282,12 +270,22 @@ fun PostActivity( } }, actions = { - IconButton(onClick = { - showSortOptions = !showSortOptions - }) { - Icon( - Icons.Outlined.Sort, - contentDescription = stringResource(R.string.selectSort), + Box { + IconButton(onClick = { + showSortOptions = true + }) { + Icon( + Icons.Outlined.Sort, + contentDescription = stringResource(R.string.selectSort), + ) + } + + CommentSortOptionsDropdown( + expanded = showSortOptions, + selectedSortType = selectedSortType, + onDismissRequest = { showSortOptions = false }, + onClickSortType = onClickSortType, + siteVersion = siteViewModel.siteVersion(), ) } }, diff --git a/app/src/main/java/com/jerboa/ui/components/post/PostListing.kt b/app/src/main/java/com/jerboa/ui/components/post/PostListing.kt index cf5c0037c..53d848cc8 100644 --- a/app/src/main/java/com/jerboa/ui/components/post/PostListing.kt +++ b/app/src/main/java/com/jerboa/ui/components/post/PostListing.kt @@ -1,6 +1,5 @@ package com.jerboa.ui.components.post -import android.widget.Toast import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -22,23 +21,14 @@ import androidx.compose.material.LocalContentAlpha import androidx.compose.material.LocalContentColor import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.Comment import androidx.compose.material.icons.outlined.CommentsDisabled -import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Description -import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material.icons.outlined.Flag import androidx.compose.material.icons.outlined.Forum import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.MoreVert -import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.PushPin -import androidx.compose.material.icons.outlined.Restore -import androidx.compose.material.icons.outlined.Share -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api @@ -50,17 +40,15 @@ import androidx.compose.runtime.Composable 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.draw.alpha import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle @@ -74,8 +62,6 @@ import com.jerboa.PostViewMode import com.jerboa.R import com.jerboa.VoteType import com.jerboa.calculateNewInstantScores -import com.jerboa.communityNameShown -import com.jerboa.copyToClipboard import com.jerboa.datatypes.sampleImagePostView import com.jerboa.datatypes.sampleLinkNoThumbnailPostView import com.jerboa.datatypes.sampleLinkPostView @@ -100,7 +86,6 @@ import com.jerboa.ui.components.common.ActionBarButtonAndBadge import com.jerboa.ui.components.common.CircularIcon import com.jerboa.ui.components.common.CommentOrPostNodeHeader import com.jerboa.ui.components.common.DotSpacer -import com.jerboa.ui.components.common.IconAndTextDrawerItem import com.jerboa.ui.components.common.MarkdownHelper.CreateMarkdownPreview import com.jerboa.ui.components.common.MyMarkdownText import com.jerboa.ui.components.common.NsfwBadge @@ -114,6 +99,7 @@ import com.jerboa.ui.components.common.scoreColor import com.jerboa.ui.components.community.CommunityLink import com.jerboa.ui.components.community.CommunityName import com.jerboa.ui.components.person.PersonProfileLink +import com.jerboa.ui.components.post.composables.PostOptionsDropdown import com.jerboa.ui.theme.ACTION_BAR_ICON_SIZE import com.jerboa.ui.theme.CARD_COLORS import com.jerboa.ui.theme.LARGER_ICON_THUMBNAIL_SIZE @@ -127,6 +113,7 @@ import com.jerboa.ui.theme.THUMBNAIL_CARET_SIZE import com.jerboa.ui.theme.XXL_PADDING import com.jerboa.ui.theme.jerboaColorScheme import com.jerboa.ui.theme.muted +import kotlinx.coroutines.CoroutineScope @Composable fun PostHeaderLine( @@ -566,51 +553,28 @@ fun PostFooterLine( viewSource: Boolean, showScores: Boolean, postActionbarMode: Int, + fromPostActivity: Boolean, + scope: CoroutineScope, ) { var showMoreOptions by remember { mutableStateOf(false) } if (showMoreOptions) { - PostOptionsDialog( + PostOptionsDropdown( postView = postView, onDismissRequest = { showMoreOptions = false }, - onEditPostClick = { - showMoreOptions = false - onEditPostClick(postView) - }, - onDeletePostClick = { - showMoreOptions = false - onDeletePostClick(postView) - }, - onCommunityClick = { - showMoreOptions = false - onCommunityClick(postView.community) - }, - onPersonClick = { - showMoreOptions = false - onPersonClick(postView.creator.id) - }, - onReportClick = { - showMoreOptions = false - onReportClick(postView) - }, - onBlockCommunityClick = { - showMoreOptions = false - onBlockCommunityClick(postView.community) - }, - onBlockCreatorClick = { - showMoreOptions = false - onBlockCreatorClick(postView.creator) - }, - onShareClick = { url -> - showMoreOptions = false - onShareClick(url) - }, - onViewSourceClick = { - showMoreOptions = false - onViewSourceClick() - }, + onCommunityClick = onCommunityClick, + onPersonClick = onPersonClick, + onEditPostClick = onEditPostClick, + onDeletePostClick = onDeletePostClick, + onReportClick = onReportClick, + onBlockCreatorClick = onBlockCreatorClick, + onBlockCommunityClick = onBlockCommunityClick, + onShareClick = onShareClick, + onViewSourceClick = onViewSourceClick, isCreator = account.id == postView.creator.id, viewSource = viewSource, + showViewSource = fromPostActivity, + scope = scope, ) } @@ -781,24 +745,26 @@ fun PostFooterLinePreview() { PostFooterLine( postView = postView, instantScores = instantScores, - account = AnonAccount, - onReportClick = {}, - onCommunityClick = {}, - onPersonClick = {}, onUpvoteClick = {}, - onSaveClick = {}, - onReplyClick = {}, onDownvoteClick = {}, + onReplyClick = {}, + onSaveClick = {}, onEditPostClick = {}, onDeletePostClick = {}, + onReportClick = {}, + onCommunityClick = {}, + onPersonClick = {}, onBlockCreatorClick = {}, onBlockCommunityClick = {}, onShareClick = {}, onViewSourceClick = {}, + account = AnonAccount, enableDownVotes = true, viewSource = false, showScores = true, postActionbarMode = PostActionbarMode.Long.ordinal, + fromPostActivity = true, + scope = rememberCoroutineScope(), ) } @@ -1374,7 +1340,7 @@ private fun ThumbnailTile( appState.showLinkPopup(url) }, - ) + ) Box { postView.post.thumbnail_url?.also { thumbnail -> @@ -1524,7 +1490,8 @@ fun PostListingCard( .padding(vertical = MEDIUM_PADDING) .clickable { onPostClick(postView) } .testTag("jerboa:post"), - verticalArrangement = Arrangement.spacedBy(LARGE_PADDING), + // see https://stackoverflow.com/questions/77010371/prevent-popup-from-adding-padding-in-a-column-with-arrangement-spacedbylarge-p + // verticalArrangement = Arrangement.spacedBy(LARGE_PADDING), ) { // Header PostHeaderLine( @@ -1541,6 +1508,8 @@ fun PostListingCard( showScores = showScores, ) + Spacer(modifier = Modifier.padding(vertical = LARGE_PADDING)) + // Title + metadata PostBody( postView = postView, @@ -1557,6 +1526,8 @@ fun PostListingCard( showIfRead = showIfRead, ) + Spacer(modifier = Modifier.padding(vertical = LARGE_PADDING)) + // Footer bar PostFooterLine( postView = postView, @@ -1579,8 +1550,10 @@ fun PostListingCard( modifier = Modifier.padding(horizontal = MEDIUM_PADDING), enableDownVotes = enableDownVotes, viewSource = viewSource, + fromPostActivity = fullBody, showScores = showScores, postActionbarMode = postActionbarMode, + scope = appState.coroutineScope, ) } } @@ -1619,263 +1592,3 @@ fun MetadataCard(post: Post) { }, ) } - -@Composable -fun PostOptionsDialog( - postView: PostView, - onDismissRequest: () -> Unit, - onCommunityClick: () -> Unit, - onPersonClick: () -> Unit, - onEditPostClick: () -> Unit, - onDeletePostClick: () -> Unit, - onReportClick: () -> Unit, - onBlockCreatorClick: () -> Unit, - onBlockCommunityClick: () -> Unit, - onShareClick: (shareUrl: String) -> Unit, - onViewSourceClick: () -> Unit, - isCreator: Boolean, - viewSource: Boolean, -) { - val localClipboardManager = LocalClipboardManager.current - val ctx = LocalContext.current - - AlertDialog( - onDismissRequest = onDismissRequest, - text = { - Column { - IconAndTextDrawerItem( - text = stringResource( - R.string.post_listing_go_to, - communityNameShown(postView.community), - ), - icon = Icons.Outlined.Forum, - onClick = { - onCommunityClick() - }, - ) - IconAndTextDrawerItem( - text = stringResource( - R.string.post_listing_go_to, - postView.creator.name, - ), - icon = Icons.Outlined.Person, - onClick = { - onPersonClick() - }, - ) - postView.post.url?.also { - IconAndTextDrawerItem( - text = stringResource(R.string.post_listing_copy_link), - icon = Icons.Outlined.Link, - onClick = { - localClipboardManager.setText(AnnotatedString(it)) - Toast.makeText( - ctx, - ctx.getString(R.string.post_listing_link_copied), - Toast.LENGTH_SHORT, - ).show() - onDismissRequest() - }, - ) - } - IconAndTextDrawerItem( - text = stringResource(R.string.post_listing_copy_permalink), - icon = Icons.Outlined.Link, - onClick = { - val permalink = postView.post.ap_id - localClipboardManager.setText(AnnotatedString(permalink)) - Toast.makeText( - ctx, - ctx.getString(R.string.post_listing_permalink_copied), - Toast.LENGTH_SHORT, - ).show() - onDismissRequest() - }, - ) - postView.post.thumbnail_url?.also { - IconAndTextDrawerItem( - text = stringResource(R.string.post_listing_copy_thumbnail_link), - icon = Icons.Outlined.Link, - onClick = { - if (copyToClipboard( - ctx, - postView.post.thumbnail_url, - "thumbnail link", - ) - ) { - Toast.makeText( - ctx, - ctx.getString(R.string.post_listing_thumbnail_link_copied), - Toast.LENGTH_SHORT, - ).show() - } else { - Toast.makeText( - ctx, - ctx.getString(R.string.generic_error), - Toast.LENGTH_SHORT, - ).show() - } - onDismissRequest() - }, - ) - } - postView.post.embed_description?.also { - IconAndTextDrawerItem( - text = stringResource(R.string.post_listing_copy_title), - icon = Icons.Outlined.ContentCopy, - onClick = { - if (copyToClipboard( - ctx, - postView.post.embed_description, - "post title", - ) - ) { - Toast.makeText( - ctx, - ctx.getString(R.string.post_listing_title_copied), - Toast.LENGTH_SHORT, - ).show() - } else { - Toast.makeText( - ctx, - ctx.getString(R.string.generic_error), - Toast.LENGTH_SHORT, - ).show() - } - onDismissRequest() - }, - ) - } - postView.post.name.also { - IconAndTextDrawerItem( - text = stringResource(R.string.post_listing_copy_name), - icon = Icons.Outlined.ContentCopy, - onClick = { - if (copyToClipboard(ctx, postView.post.name, "post name")) { - Toast.makeText( - ctx, - ctx.getString(R.string.post_listing_name_copied), - Toast.LENGTH_SHORT, - ).show() - } else { - Toast.makeText( - ctx, - ctx.getString(R.string.generic_error), - Toast.LENGTH_SHORT, - ).show() - } - onDismissRequest() - }, - ) - } - postView.post.body?.also { - IconAndTextDrawerItem( - text = stringResource(R.string.post_listing_copy_text), - icon = Icons.Outlined.ContentCopy, - onClick = { - if (copyToClipboard(ctx, postView.post.body, "post text")) { - Toast.makeText( - ctx, - ctx.getString(R.string.post_listing_text_copied), - Toast.LENGTH_SHORT, - ).show() - } else { - Toast.makeText( - ctx, - ctx.getString(R.string.generic_error), - Toast.LENGTH_SHORT, - ).show() - } - onDismissRequest() - }, - ) - } - postView.post.body?.also { - IconAndTextDrawerItem( - text = if (viewSource) { - stringResource(R.string.post_listing_view_original) - } else { - stringResource( - R.string.post_listing_view_source, - ) - }, - icon = Icons.Outlined.Description, - onClick = onViewSourceClick, - ) - } - postView.post.url?.also { url -> - IconAndTextDrawerItem( - text = stringResource(R.string.post_listing_share_link), - icon = Icons.Outlined.Share, - onClick = { onShareClick(url) }, - ) - } - IconAndTextDrawerItem( - text = stringResource(R.string.post_listing_share_post), - icon = Icons.Outlined.Share, - onClick = { onShareClick(postView.post.ap_id) }, - ) - if (!isCreator) { - Divider(Modifier.padding(LARGE_PADDING)) - IconAndTextDrawerItem( - text = stringResource(R.string.post_listing_block, postView.creator.name), - icon = Icons.Outlined.Block, - onClick = onBlockCreatorClick, - ) - IconAndTextDrawerItem( - text = stringResource(R.string.post_listing_block, postView.community.name), - icon = Icons.Outlined.Block, - onClick = onBlockCommunityClick, - ) - IconAndTextDrawerItem( - text = stringResource(R.string.post_listing_report_post), - icon = Icons.Outlined.Flag, - onClick = onReportClick, - ) - } - if (isCreator) { - IconAndTextDrawerItem( - text = stringResource(R.string.post_listing_edit), - icon = Icons.Outlined.Edit, - onClick = onEditPostClick, - ) - val deleted = postView.post.deleted - if (deleted) { - IconAndTextDrawerItem( - text = stringResource(R.string.post_listing_restore), - icon = Icons.Outlined.Restore, - onClick = onDeletePostClick, - ) - } else { - IconAndTextDrawerItem( - text = stringResource(R.string.post_listing_delete), - icon = Icons.Outlined.Delete, - onClick = onDeletePostClick, - ) - } - } - } - }, - confirmButton = {}, - ) -} - -@Preview -@Composable -fun PostOptionsDialogPreview() { - PostOptionsDialog( - postView = samplePostView, - isCreator = true, - onReportClick = {}, - onCommunityClick = {}, - onPersonClick = {}, - onDismissRequest = {}, - onEditPostClick = {}, - onDeletePostClick = {}, - onBlockCommunityClick = {}, - onShareClick = {}, - onBlockCreatorClick = {}, - onViewSourceClick = {}, - viewSource = true, - ) -} diff --git a/app/src/main/java/com/jerboa/ui/components/post/composables/PostOptionsDropdown.kt b/app/src/main/java/com/jerboa/ui/components/post/composables/PostOptionsDropdown.kt new file mode 100644 index 000000000..d62f7347a --- /dev/null +++ b/app/src/main/java/com/jerboa/ui/components/post/composables/PostOptionsDropdown.kt @@ -0,0 +1,363 @@ +package com.jerboa.ui.components.post.composables + +import android.widget.Toast +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.CopyAll +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Flag +import androidx.compose.material.icons.outlined.Forum +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Restore +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.Divider +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import com.jerboa.PostType +import com.jerboa.R +import com.jerboa.communityNameShown +import com.jerboa.copyToClipboard +import com.jerboa.datatypes.types.Community +import com.jerboa.datatypes.types.Person +import com.jerboa.datatypes.types.PersonId +import com.jerboa.datatypes.types.PostView +import com.jerboa.feat.shareMedia +import com.jerboa.isMedia +import com.jerboa.ui.components.common.PopupMenuItem +import com.jerboa.util.cascade.CascadeCenteredDropdownMenu +import kotlinx.coroutines.CoroutineScope + +@Composable +fun PostOptionsDropdown( + postView: PostView, + onDismissRequest: () -> Unit, + onCommunityClick: (Community) -> Unit, + onPersonClick: (PersonId) -> Unit, + onEditPostClick: (PostView) -> Unit, + onDeletePostClick: (PostView) -> Unit, + onReportClick: (PostView) -> Unit, + onBlockCreatorClick: (Person) -> Unit, + onBlockCommunityClick: (Community) -> Unit, + onShareClick: (shareUrl: String) -> Unit, + onViewSourceClick: () -> Unit, + isCreator: Boolean, + viewSource: Boolean, + showViewSource: Boolean, + scope: CoroutineScope, +) { + val ctx = LocalContext.current + val localClipboardManager = LocalClipboardManager.current + + CascadeCenteredDropdownMenu( + expanded = true, + onDismissRequest = onDismissRequest, + ) { + PopupMenuItem( + text = stringResource(R.string.post_listing_go_to, communityNameShown(postView.community)), + icon = Icons.Outlined.Forum, + onClick = { + onDismissRequest() + onCommunityClick(postView.community) + }, + ) + + PopupMenuItem( + text = stringResource(R.string.post_listing_go_to, postView.creator.name), + icon = Icons.Outlined.Person, + onClick = { + onDismissRequest() + onPersonClick(postView.creator.id) + }, + ) + + PopupMenuItem( + text = stringResource(R.string.copy), + icon = Icons.Outlined.CopyAll, + ) { + postView.post.url?.also { + PopupMenuItem( + text = stringResource(R.string.post_listing_copy_link), + icon = Icons.Outlined.Link, + onClick = { + onDismissRequest() + localClipboardManager.setText(AnnotatedString(it)) + Toast.makeText( + ctx, + ctx.getString(R.string.post_listing_link_copied), + Toast.LENGTH_SHORT, + ).show() + }, + ) + } + + PopupMenuItem( + text = stringResource(R.string.post_listing_copy_permalink), + icon = Icons.Outlined.Link, + onClick = { + onDismissRequest() + val permalink = postView.post.ap_id + localClipboardManager.setText(AnnotatedString(permalink)) + Toast.makeText( + ctx, + ctx.getString(R.string.post_listing_permalink_copied), + Toast.LENGTH_SHORT, + ).show() + }, + ) + + postView.post.thumbnail_url?.also { + PopupMenuItem( + text = stringResource(R.string.post_listing_copy_thumbnail_link), + icon = Icons.Outlined.Link, + onClick = { + onDismissRequest() + if (copyToClipboard( + ctx, + postView.post.thumbnail_url, + "thumbnail link", + ) + ) { + Toast.makeText( + ctx, + ctx.getString(R.string.post_listing_thumbnail_link_copied), + Toast.LENGTH_SHORT, + ).show() + } else { + Toast.makeText( + ctx, + ctx.getString(R.string.generic_error), + Toast.LENGTH_SHORT, + ).show() + } + }, + ) + } + + postView.post.embed_description?.also { + PopupMenuItem( + text = stringResource(R.string.post_listing_copy_title), + icon = Icons.Outlined.ContentCopy, + onClick = { + onDismissRequest() + if (copyToClipboard( + ctx, + postView.post.embed_description, + "post title", + ) + ) { + Toast.makeText( + ctx, + ctx.getString(R.string.post_listing_title_copied), + Toast.LENGTH_SHORT, + ).show() + } else { + Toast.makeText( + ctx, + ctx.getString(R.string.generic_error), + Toast.LENGTH_SHORT, + ).show() + } + }, + ) + } + + postView.post.name.also { + PopupMenuItem( + text = stringResource(R.string.post_listing_copy_name), + icon = Icons.Outlined.ContentCopy, + onClick = { + onDismissRequest() + if (copyToClipboard(ctx, postView.post.name, "post name")) { + Toast.makeText( + ctx, + ctx.getString(R.string.post_listing_name_copied), + Toast.LENGTH_SHORT, + ).show() + } else { + Toast.makeText( + ctx, + ctx.getString(R.string.generic_error), + Toast.LENGTH_SHORT, + ).show() + } + }, + ) + } + + postView.post.body?.also { + PopupMenuItem( + text = stringResource(R.string.post_listing_copy_text), + icon = Icons.Outlined.ContentCopy, + onClick = { + onDismissRequest() + if (copyToClipboard(ctx, postView.post.body, "post text")) { + Toast.makeText( + ctx, + ctx.getString(R.string.post_listing_text_copied), + Toast.LENGTH_SHORT, + ).show() + } else { + Toast.makeText( + ctx, + ctx.getString(R.string.generic_error), + Toast.LENGTH_SHORT, + ).show() + } + }, + ) + } + } + + PopupMenuItem( + text = stringResource(R.string.share), + icon = Icons.Outlined.Share, + ) { + postView.post.url?.also { url -> + PopupMenuItem( + text = stringResource(R.string.post_listing_share_link), + icon = Icons.Outlined.Share, + onClick = { + onDismissRequest() + onShareClick(url) + }, + ) + + val mediaType = PostType.fromURL(url) + + when (mediaType) { + PostType.Image -> + PopupMenuItem( + text = stringResource(R.string.share_image), + icon = Icons.Outlined.Share, + onClick = { + onDismissRequest() + shareMedia(scope, ctx, url, mediaType) + }, + ) + + PostType.Video -> + PopupMenuItem( + text = stringResource(R.string.share_video), + icon = Icons.Outlined.Share, + onClick = { + onDismissRequest() + shareMedia(scope, ctx, url, mediaType) + }, + ) + + PostType.Link -> + if (isMedia(url)) { + PopupMenuItem( + text = stringResource(R.string.share_media), + icon = Icons.Outlined.Share, + onClick = { + onDismissRequest() + shareMedia(scope, ctx, url, PostType.Link) + }, + ) + } + } + } + + PopupMenuItem( + text = stringResource(R.string.post_listing_share_post), + icon = Icons.Outlined.Share, + onClick = { + onDismissRequest() + onShareClick(postView.post.ap_id) + }, + ) + } + + // Only visible from PostActivity + if (showViewSource) { + postView.post.body?.also { + PopupMenuItem( + text = if (viewSource) { + stringResource(R.string.post_listing_view_original) + } else { + stringResource(R.string.post_listing_view_source) + }, + icon = Icons.Outlined.Description, + onClick = { + onDismissRequest() + onViewSourceClick() + }, + ) + } + } + + Divider() + + if (isCreator) { + PopupMenuItem( + text = stringResource(R.string.post_listing_edit), + icon = Icons.Outlined.Edit, + onClick = { + onDismissRequest() + onEditPostClick(postView) + }, + ) + + if (postView.post.deleted) { + PopupMenuItem( + text = stringResource(R.string.post_listing_restore), + icon = Icons.Outlined.Restore, + onClick = { + onDismissRequest() + onDeletePostClick(postView) + }, + ) + } else { + PopupMenuItem( + text = stringResource(R.string.post_listing_delete), + icon = Icons.Outlined.Delete, + onClick = { + onDismissRequest() + onDeletePostClick(postView) + }, + ) + } + } else { + PopupMenuItem( + // Reuse existing translations + text = stringResource(R.string.post_listing_block, ""), + icon = Icons.Outlined.Block, + ) { + PopupMenuItem( + text = stringResource(R.string.post_listing_block, postView.creator.name), + icon = Icons.Outlined.Block, + onClick = { + onDismissRequest() + onBlockCreatorClick(postView.creator) + }, + ) + + PopupMenuItem( + text = stringResource(R.string.post_listing_block, postView.community.name), + icon = Icons.Outlined.Block, + onClick = { + onDismissRequest() + onBlockCommunityClick(postView.community) + }, + ) + } + + PopupMenuItem( + text = stringResource(R.string.post_listing_report_post), + icon = Icons.Outlined.Flag, + onClick = { + onDismissRequest() + onReportClick(postView) + }, + ) + } + } +} diff --git a/app/src/main/java/com/jerboa/util/cascade/Cascade.kt b/app/src/main/java/com/jerboa/util/cascade/Cascade.kt index ec8f74527..bcc2f3c43 100644 --- a/app/src/main/java/com/jerboa/util/cascade/Cascade.kt +++ b/app/src/main/java/com/jerboa/util/cascade/Cascade.kt @@ -121,6 +121,7 @@ fun CascadeDropdownMenu( offset: DpOffset = DpOffset.Zero, fixedWidth: Dp = 196.dp, shadowElevation: Dp = 3.dp, + tonalElevation: Dp = 3.dp, properties: PopupProperties = PopupProperties(focusable = true), state: CascadeState = rememberCascadeState(), content: @Composable CascadeColumnScope.() -> Unit, @@ -180,6 +181,7 @@ fun CascadeDropdownMenu( expandedStates = expandedStates, transformOriginState = transformOriginState, shadowElevation = shadowElevation, + tonalElevation = tonalElevation, content = content, ) } @@ -192,6 +194,7 @@ internal fun PopupContent( modifier: Modifier = Modifier, state: CascadeState, fixedWidth: Dp, + tonalElevation: Dp, shadowElevation: Dp, expandedStates: MutableTransitionState, transformOriginState: MutableState, @@ -211,7 +214,8 @@ internal fun PopupContent( .requiredWidth(fixedWidth) .then(modifier), state = state, - tonalElevation = shadowElevation, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation, content = content, ) } @@ -222,6 +226,7 @@ private fun CascadeDropdownMenuContent( state: CascadeState, modifier: Modifier = Modifier, tonalElevation: Dp, + shadowElevation: Dp, content: @Composable CascadeColumnScope.() -> Unit, ) { DisposableEffect(Unit) { @@ -232,8 +237,9 @@ private fun CascadeDropdownMenuContent( Surface( shape = MaterialTheme.shapes.extraSmall, - color = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + color = MaterialTheme.colorScheme.surface, tonalElevation = tonalElevation, + shadowElevation = shadowElevation, ) { val isTransitionRunning = remember { MutableStateFlow(false) } val backStackSnapshot by remember { @@ -257,7 +263,7 @@ private fun CascadeDropdownMenuContent( Modifier // Provide a solid background color to prevent the // content of sub-menus from leaking into each other. - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(tonalElevation)) .verticalScroll(rememberScrollState()), ) { val currentContent = snapshot.topMostEntry?.childrenContent ?: content diff --git a/app/src/main/java/com/jerboa/util/cascade/CustomCascadeDropdown.kt b/app/src/main/java/com/jerboa/util/cascade/CustomCascadeDropdown.kt new file mode 100644 index 000000000..f283e1b0c --- /dev/null +++ b/app/src/main/java/com/jerboa/util/cascade/CustomCascadeDropdown.kt @@ -0,0 +1,77 @@ +package com.jerboa.util.cascade + +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +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.graphics.TransformOrigin +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.jerboa.ui.theme.LARGE_PADDING +import com.jerboa.util.cascade.internal.clickableWithoutRipple +import com.jerboa.util.cascade.internal.copy +import com.jerboa.util.cascade.internal.then + +@Composable +fun CascadeCenteredDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + fixedWidth: Dp = LocalConfiguration.current.screenWidthDp.dp * 0.86f, + shadowElevation: Dp = 3.dp, + properties: PopupProperties = PopupProperties(focusable = true), + state: CascadeState = rememberCascadeState(), + content: @Composable CascadeColumnScope.() -> Unit, +) { + val expandedStates = remember { MutableTransitionState(false) } + expandedStates.targetState = expanded + + if (expandedStates.currentState || expandedStates.targetState) { + val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) } + + // A full sized popup is shown so that content can render fake shadows + // that do not suffer from https://issuetracker.google.com/issues/236109671. + + Popup( + alignment = Alignment.Center, + onDismissRequest = onDismissRequest, + properties = properties.copy(usePlatformDefaultWidth = false), + ) { + Box( + Modifier + .fillMaxSize() + .then(properties.dismissOnClickOutside) { + clickableWithoutRipple(onClick = onDismissRequest) + }, + Alignment.Center, + ) { + PopupContent( + modifier = Modifier + // Prevent clicks from leaking behind. Otherwise, they'll get picked up as outside + // clicks to dismiss the popup. This must be set _before_ the downstream modifiers to + // avoid overriding any clickable modifiers registered by the developer. + .clickableWithoutRipple {} + .padding(vertical = LARGE_PADDING) + .then(modifier), + state = state, + fixedWidth = fixedWidth, + expandedStates = expandedStates, + transformOriginState = transformOriginState, + shadowElevation = shadowElevation, + tonalElevation = 3.dp, + content = content, + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d5578e23..596d84778 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -411,4 +411,6 @@ Open link Open link in external No activity (app) found that can open this link + Copy + Share media From bd8705b6d4c4cb4c1f5acc3d2024f50d0a40b2b1 Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Thu, 31 Aug 2023 01:52:54 +0200 Subject: [PATCH 07/10] Remove remnants --- app/src/main/java/com/jerboa/MainActivity.kt | 8 -------- .../java/com/jerboa/ui/components/post/PostListing.kt | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/app/src/main/java/com/jerboa/MainActivity.kt b/app/src/main/java/com/jerboa/MainActivity.kt index 205531e57..927e15f52 100644 --- a/app/src/main/java/com/jerboa/MainActivity.kt +++ b/app/src/main/java/com/jerboa/MainActivity.kt @@ -168,14 +168,6 @@ class MainActivity : AppCompatActivity() { appSettings.usePrivateTabs, ) -// CommentSortOptionsDropdownTestOld( -// expanded = appState.linkDropdownExpanded.value != null, -// onDismissRequest = appState::hideLinkPopup, -// siteVersion = MINIMUM_API_VERSION, -// onClickSortType = {}, -// selectedSortType = SortType.Hot -// ) - ShowChangelog(appSettingsViewModel = appSettingsViewModel) when (val siteRes = siteViewModel.siteRes) { diff --git a/app/src/main/java/com/jerboa/ui/components/post/PostListing.kt b/app/src/main/java/com/jerboa/ui/components/post/PostListing.kt index 53d848cc8..5edb058bf 100644 --- a/app/src/main/java/com/jerboa/ui/components/post/PostListing.kt +++ b/app/src/main/java/com/jerboa/ui/components/post/PostListing.kt @@ -1340,7 +1340,7 @@ private fun ThumbnailTile( appState.showLinkPopup(url) }, - ) + ) Box { postView.post.thumbnail_url?.also { thumbnail -> From 91d41b86704d6a3814e99ac74da25a4656f9ade1 Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Thu, 31 Aug 2023 02:03:35 +0200 Subject: [PATCH 08/10] Fix lint --- .../{cascadeTransitionSpec.kt => CascadeTransitionSpec.kt} | 0 .../{clickableWithoutRipple.kt => ClickableWithoutRipple.kt} | 0 .../cascade/internal/{popupProperties.kt => PopupProperties.kt} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename app/src/main/java/com/jerboa/util/cascade/internal/{cascadeTransitionSpec.kt => CascadeTransitionSpec.kt} (100%) rename app/src/main/java/com/jerboa/util/cascade/internal/{clickableWithoutRipple.kt => ClickableWithoutRipple.kt} (100%) rename app/src/main/java/com/jerboa/util/cascade/internal/{popupProperties.kt => PopupProperties.kt} (100%) diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/cascadeTransitionSpec.kt b/app/src/main/java/com/jerboa/util/cascade/internal/CascadeTransitionSpec.kt similarity index 100% rename from app/src/main/java/com/jerboa/util/cascade/internal/cascadeTransitionSpec.kt rename to app/src/main/java/com/jerboa/util/cascade/internal/CascadeTransitionSpec.kt diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/clickableWithoutRipple.kt b/app/src/main/java/com/jerboa/util/cascade/internal/ClickableWithoutRipple.kt similarity index 100% rename from app/src/main/java/com/jerboa/util/cascade/internal/clickableWithoutRipple.kt rename to app/src/main/java/com/jerboa/util/cascade/internal/ClickableWithoutRipple.kt diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/popupProperties.kt b/app/src/main/java/com/jerboa/util/cascade/internal/PopupProperties.kt similarity index 100% rename from app/src/main/java/com/jerboa/util/cascade/internal/popupProperties.kt rename to app/src/main/java/com/jerboa/util/cascade/internal/PopupProperties.kt From 82b2b62a84fba403e9022aacbe65f46167e768ca Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Thu, 31 Aug 2023 02:26:32 +0200 Subject: [PATCH 09/10] Use constant --- .../main/java/com/jerboa/ui/components/common/DropdownMenu.kt | 3 ++- app/src/main/java/com/jerboa/ui/theme/Sizes.kt | 2 ++ .../main/java/com/jerboa/util/cascade/CustomCascadeDropdown.kt | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/jerboa/ui/components/common/DropdownMenu.kt b/app/src/main/java/com/jerboa/ui/components/common/DropdownMenu.kt index e9418afd9..0ec5c38cf 100644 --- a/app/src/main/java/com/jerboa/ui/components/common/DropdownMenu.kt +++ b/app/src/main/java/com/jerboa/ui/components/common/DropdownMenu.kt @@ -29,6 +29,7 @@ import com.jerboa.R import com.jerboa.datatypes.types.CommentSortType import com.jerboa.datatypes.types.SortType import com.jerboa.ui.theme.LARGE_PADDING +import com.jerboa.ui.theme.POPUP_MENU_WIDTH_RATIO import com.jerboa.ui.theme.Shapes import com.jerboa.util.cascade.CascadeColumnScope import com.jerboa.util.cascade.CascadeDropdownMenu @@ -244,7 +245,7 @@ fun CenteredPopupMenu( ) { Column( modifier = Modifier - .fillMaxWidth(0.86f) + .fillMaxWidth(POPUP_MENU_WIDTH_RATIO) .padding(vertical = LARGE_PADDING), content = content, ) diff --git a/app/src/main/java/com/jerboa/ui/theme/Sizes.kt b/app/src/main/java/com/jerboa/ui/theme/Sizes.kt index 34e227cee..0fb3b4904 100644 --- a/app/src/main/java/com/jerboa/ui/theme/Sizes.kt +++ b/app/src/main/java/com/jerboa/ui/theme/Sizes.kt @@ -32,3 +32,5 @@ const val ICON_THUMBNAIL_SIZE = 96 const val LARGER_ICON_THUMBNAIL_SIZE = 256 const val THUMBNAIL_SIZE = 256 const val MAX_IMAGE_SIZE = 3000 + +const val POPUP_MENU_WIDTH_RATIO = 0.86f diff --git a/app/src/main/java/com/jerboa/util/cascade/CustomCascadeDropdown.kt b/app/src/main/java/com/jerboa/util/cascade/CustomCascadeDropdown.kt index f283e1b0c..8c7aa0161 100644 --- a/app/src/main/java/com/jerboa/util/cascade/CustomCascadeDropdown.kt +++ b/app/src/main/java/com/jerboa/util/cascade/CustomCascadeDropdown.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import com.jerboa.ui.theme.LARGE_PADDING +import com.jerboa.ui.theme.POPUP_MENU_WIDTH_RATIO import com.jerboa.util.cascade.internal.clickableWithoutRipple import com.jerboa.util.cascade.internal.copy import com.jerboa.util.cascade.internal.then @@ -27,7 +28,7 @@ fun CascadeCenteredDropdownMenu( expanded: Boolean, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, - fixedWidth: Dp = LocalConfiguration.current.screenWidthDp.dp * 0.86f, + fixedWidth: Dp = LocalConfiguration.current.screenWidthDp.dp * POPUP_MENU_WIDTH_RATIO, shadowElevation: Dp = 3.dp, properties: PopupProperties = PopupProperties(focusable = true), state: CascadeState = rememberCascadeState(), From ffd659da8b9a971c8d0b20bd5f32dccb1847dcc8 Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Thu, 31 Aug 2023 02:38:51 +0200 Subject: [PATCH 10/10] Trigger woodpecker