Add documentation to LongPressMenu and simplify code
Also add 2 tests to make sure all menu buttons are the same size Also simplify some code in LongPressMenu instantiation Also make the LongPressMenuEditor appear on top of the LongPressMenu instead of closing and reopening the LongPressMenu
This commit is contained in:
parent
3fc4bc9cd3
commit
94608137ef
@ -6,6 +6,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.semantics.SemanticsProperties.ProgressBarRangeInfo
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assert
|
||||
@ -130,20 +131,17 @@ class LongPressMenuTest {
|
||||
composeRule.onNodeWithContentDescription(R.string.long_press_menu_actions_editor)
|
||||
.performClick()
|
||||
composeRule.waitUntil {
|
||||
composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions)
|
||||
composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions_description)
|
||||
.isDisplayed()
|
||||
}
|
||||
|
||||
composeRule.onNodeWithContentDescription(R.string.long_press_menu_actions_editor)
|
||||
.assertDoesNotExist()
|
||||
Espresso.pressBack()
|
||||
composeRule.waitUntil {
|
||||
composeRule.onNodeWithContentDescription(R.string.long_press_menu_actions_editor)
|
||||
.isDisplayed()
|
||||
composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions_description)
|
||||
.isNotDisplayed()
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions)
|
||||
.assertDoesNotExist()
|
||||
composeRule.onNodeWithContentDescription(R.string.long_press_menu_actions_editor)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -392,14 +390,7 @@ class LongPressMenuTest {
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
|
||||
fun testHeaderSpansAllWidthIfSmallScreen() {
|
||||
onDevice().setDisplaySize(
|
||||
widthSizeClass = WidthSizeClass.COMPACT,
|
||||
heightSizeClass = HeightSizeClass.MEDIUM
|
||||
)
|
||||
setLongPressMenu()
|
||||
private fun getFirstRowAndHeaderBounds(): Pair<Rect, Rect> {
|
||||
val row = composeRule
|
||||
.onAllNodesWithTag("LongPressMenuGridRow")
|
||||
.onFirst()
|
||||
@ -408,7 +399,26 @@ class LongPressMenuTest {
|
||||
val header = composeRule.onNodeWithTag("LongPressMenuHeader")
|
||||
.fetchSemanticsNode()
|
||||
.boundsInRoot
|
||||
return Pair(row, header)
|
||||
}
|
||||
|
||||
private fun assertAllButtonsSameSize() {
|
||||
composeRule.onAllNodesWithTag("LongPressMenuButton")
|
||||
.fetchSemanticsNodes()
|
||||
.reduce { prev, curr ->
|
||||
assertInRange(prev.size.height - 1, prev.size.height + 1, curr.size.height)
|
||||
assertInRange(prev.size.width - 1, prev.size.width + 1, curr.size.width)
|
||||
return@reduce curr
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
|
||||
fun testHeaderSpansAllWidthIfSmallScreen() {
|
||||
onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.MEDIUM)
|
||||
setLongPressMenu()
|
||||
// checks that the header is roughly as large as the row that contains it
|
||||
val (row, header) = getFirstRowAndHeaderBounds()
|
||||
assertInRange(row.left, row.left + 24.dp.value, header.left)
|
||||
assertInRange(row.right - 24.dp.value, row.right, header.right)
|
||||
}
|
||||
@ -416,24 +426,31 @@ class LongPressMenuTest {
|
||||
@Test
|
||||
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
|
||||
fun testHeaderIsNotFullWidthIfLargeScreen() {
|
||||
onDevice().setDisplaySize(
|
||||
widthSizeClass = WidthSizeClass.EXPANDED,
|
||||
heightSizeClass = HeightSizeClass.MEDIUM
|
||||
)
|
||||
onDevice().setDisplaySize(WidthSizeClass.EXPANDED, HeightSizeClass.MEDIUM)
|
||||
setLongPressMenu()
|
||||
val row = composeRule
|
||||
.onAllNodesWithTag("LongPressMenuGridRow")
|
||||
.onFirst()
|
||||
.fetchSemanticsNode()
|
||||
.boundsInRoot
|
||||
val header = composeRule.onNodeWithTag("LongPressMenuHeader")
|
||||
.fetchSemanticsNode()
|
||||
.boundsInRoot
|
||||
|
||||
// checks that the header is definitely smaller than the row that contains it
|
||||
val (row, header) = getFirstRowAndHeaderBounds()
|
||||
assertInRange(row.left, row.left + 24.dp.value, header.left)
|
||||
assertNotInRange(row.right - 24.dp.value, Float.MAX_VALUE, header.right)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
|
||||
fun testAllButtonsSameSizeSmallScreen() {
|
||||
onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.MEDIUM)
|
||||
setLongPressMenu()
|
||||
assertAllButtonsSameSize()
|
||||
}
|
||||
|
||||
@Test
|
||||
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
|
||||
fun testAllButtonsSameSizeLargeScreen() {
|
||||
onDevice().setDisplaySize(WidthSizeClass.EXPANDED, HeightSizeClass.MEDIUM)
|
||||
setLongPressMenu()
|
||||
assertAllButtonsSameSize()
|
||||
}
|
||||
|
||||
/**
|
||||
* The tests below all call this function to test, under different conditions, that the shown
|
||||
* actions are the intersection between the available and the enabled actions.
|
||||
@ -545,7 +562,7 @@ class LongPressMenuTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFewActionsOnLargeScreenAreNotScrollable() {
|
||||
fun testFewActionsOnNormalScreenAreNotScrollable() {
|
||||
assertOnlyAndAllArrangedActionsDisplayed(
|
||||
availableActions = listOf(ShowDetails, ShowChannelDetails),
|
||||
actionArrangement = listOf(ShowDetails, ShowChannelDetails),
|
||||
@ -570,10 +587,7 @@ class LongPressMenuTest {
|
||||
@Test
|
||||
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24
|
||||
fun testAllActionsOnSmallScreenAreScrollable() {
|
||||
onDevice().setDisplaySize(
|
||||
widthSizeClass = WidthSizeClass.COMPACT,
|
||||
heightSizeClass = HeightSizeClass.COMPACT
|
||||
)
|
||||
onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.COMPACT)
|
||||
assertOnlyAndAllArrangedActionsDisplayed(
|
||||
availableActions = LongPressAction.Type.entries,
|
||||
actionArrangement = LongPressAction.Type.entries,
|
||||
|
||||
@ -102,6 +102,12 @@ data class LongPressAction(
|
||||
val action: suspend (context: Context) -> Unit
|
||||
) {
|
||||
/**
|
||||
* When adding a new action, make sure to pick a unique [id] for it. Also, if the newly added
|
||||
* action is to be considered a default action, add it to
|
||||
* `LongPressMenuSettings.DefaultEnabledActions`, and create a settings migration to add it to
|
||||
* the user's actions (otherwise the action will be disabled by default and the user will never
|
||||
* find out about it).
|
||||
*
|
||||
* @param id a unique ID that allows saving and restoring a list of action types from settings.
|
||||
* **MUST NOT CHANGE ACROSS APP VERSIONS!**
|
||||
* @param label a string label to show in the action's button
|
||||
@ -138,6 +144,7 @@ data class LongPressAction(
|
||||
Unsubscribe(23, R.string.unsubscribe, Icons.Default.RemoveCircle),
|
||||
Delete(24, R.string.delete, Icons.Default.Delete),
|
||||
Remove(25, R.string.play_queue_remove, Icons.Default.Delete)
|
||||
// READ THE Type ENUM JAVADOC BEFORE ADDING OR CHANGING ACTIONS!
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@ -5,6 +5,7 @@ package org.schabi.newpipe.ui.components.menu
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import androidx.compose.animation.fadeIn
|
||||
@ -89,6 +90,7 @@ import java.time.OffsetDateTime
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.error.ErrorUtil
|
||||
@ -106,44 +108,56 @@ import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.text.FixedHeightCenteredText
|
||||
import org.schabi.newpipe.util.text.fadedMarquee
|
||||
|
||||
internal val MinButtonWidth = 86.dp
|
||||
internal val ThumbnailHeight = 60.dp
|
||||
private const val TAG = "LongPressMenu"
|
||||
|
||||
/**
|
||||
* Opens the long press menu from a View UI. From a Compose UI, use [LongPressMenu] directly.
|
||||
*/
|
||||
fun openLongPressMenuInActivity(
|
||||
activity: Activity,
|
||||
longPressable: LongPressable,
|
||||
longPressActions: List<LongPressAction>
|
||||
) {
|
||||
val composeView = ComposeView(activity)
|
||||
composeView.setContent {
|
||||
AppTheme {
|
||||
LongPressMenu(
|
||||
longPressable = longPressable,
|
||||
longPressActions = longPressActions,
|
||||
onDismissRequest = { (composeView.parent as? ViewGroup)?.removeView(composeView) }
|
||||
)
|
||||
}
|
||||
}
|
||||
activity.addContentView(
|
||||
getLongPressMenuView(activity, longPressable, longPressActions),
|
||||
composeView,
|
||||
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
)
|
||||
}
|
||||
|
||||
fun getLongPressMenuView(
|
||||
context: Context,
|
||||
longPressable: LongPressable,
|
||||
longPressActions: List<LongPressAction>
|
||||
): ComposeView {
|
||||
return ComposeView(context).apply {
|
||||
setContent {
|
||||
AppTheme {
|
||||
LongPressMenu(
|
||||
longPressable = longPressable,
|
||||
longPressActions = longPressActions,
|
||||
onDismissRequest = { (this.parent as? ViewGroup)?.removeView(this) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val MinButtonWidth = 86.dp
|
||||
internal val ThumbnailHeight = 60.dp
|
||||
|
||||
/**
|
||||
* Shows a bottom sheet menu containing a small header with the information in [longPressable], and
|
||||
* then a list of actions that the user can perform on that item.
|
||||
*
|
||||
* @param longPressable contains information about the item that was just long-pressed, this
|
||||
* information will be shown in a small header at the top of the menu, unless the user disabled it
|
||||
* @param longPressActions should contain a list of all *applicable* actions for the item, and this
|
||||
* composable's implementation will take care of filtering out the actions that the user has not
|
||||
* disabled in settings. For more info see [LongPressAction]
|
||||
* @param onDismissRequest called when the [LongPressMenu] should be closed, because the user either
|
||||
* dismissed it or chose an action
|
||||
*/
|
||||
@Composable
|
||||
fun LongPressMenu(
|
||||
longPressable: LongPressable,
|
||||
longPressActions: List<LongPressAction>,
|
||||
onDismissRequest: () -> Unit
|
||||
) {
|
||||
// there are three possible states for the long press menu:
|
||||
// - the starting state, with the menu shown
|
||||
// - the loading state, after a user presses on an action that takes some time to be performed
|
||||
// - the editor state, after the user clicks on the editor button in the top right
|
||||
val viewModel: LongPressMenuViewModel = viewModel()
|
||||
val isHeaderEnabled by viewModel.isHeaderEnabled.collectAsState()
|
||||
val actionArrangement by viewModel.actionArrangement.collectAsState()
|
||||
@ -151,86 +165,111 @@ fun LongPressMenu(
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
// intersection between applicable actions (longPressActions) and actions that the user
|
||||
// enabled in settings (actionArrangement)
|
||||
val enabledLongPressActions by remember {
|
||||
derivedStateOf {
|
||||
actionArrangement.mapNotNull { type ->
|
||||
longPressActions.firstOrNull { it.type == type }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ctx = LocalContext.current
|
||||
// run actions on the main thread!
|
||||
val coroutineScope = rememberCoroutineScope { Dispatchers.Main }
|
||||
fun runActionAndDismiss(action: LongPressAction) {
|
||||
if (isLoading) {
|
||||
return // shouldn't be reachable, but just in case, prevent running two actions
|
||||
}
|
||||
isLoading = true
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
action.action(ctx)
|
||||
} catch (_: CancellationException) {
|
||||
// the user canceled the action, e.g. by dismissing the dialog while loading
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "Got CancellationException while running action ${action.type}")
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
ErrorUtil.showSnackbar(
|
||||
ctx,
|
||||
ErrorInfo(t, LONG_PRESS_MENU_ACTION, "Running action ${action.type}")
|
||||
)
|
||||
}
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
|
||||
// show a clickable uploader in the header if an uploader action is available and the
|
||||
// "show channel details" action is not enabled as a standalone action
|
||||
val onUploaderClick by remember {
|
||||
derivedStateOf {
|
||||
longPressActions.firstOrNull { it.type == ShowChannelDetails }
|
||||
?.takeIf { !actionArrangement.contains(ShowChannelDetails) }
|
||||
?.let { showChannelAction -> { runActionAndDismiss(showChannelAction) } }
|
||||
}
|
||||
}
|
||||
|
||||
// takes care of showing either the actions or a loading indicator in a bottom sheet
|
||||
ModalBottomSheet(
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = onDismissRequest,
|
||||
dragHandle = { LongPressMenuDragHandle(onEditActions = { showEditor = true }) }
|
||||
) {
|
||||
// this Box and the .matchParentSize() below make sure that once the loading starts, the
|
||||
// bottom sheet menu size remains the same and the loading button is shown in the middle
|
||||
Box(modifier = Modifier.discardAllTouchesIf(isLoading)) {
|
||||
LongPressMenuContent(
|
||||
header = longPressable.takeIf { isHeaderEnabled },
|
||||
onUploaderClick = onUploaderClick,
|
||||
actions = enabledLongPressActions,
|
||||
runActionAndDismiss = ::runActionAndDismiss
|
||||
)
|
||||
// importing makes the ColumnScope overload be resolved, so we use qualified path...
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = isLoading,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// takes care of showing the editor screen
|
||||
if (showEditor && !isLoading) {
|
||||
// we can't put the editor in a bottom sheet, because it relies on dragging gestures
|
||||
// we can't put the editor in a bottom sheet, because it relies on dragging gestures and it
|
||||
// benefits from a bigger screen, so we use a fullscreen dialog instead
|
||||
Dialog(
|
||||
onDismissRequest = { showEditor = false },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
LongPressMenuEditorPage { showEditor = false }
|
||||
}
|
||||
} else {
|
||||
val enabledLongPressActions by remember {
|
||||
derivedStateOf {
|
||||
actionArrangement.mapNotNull { type ->
|
||||
longPressActions.firstOrNull { it.type == type }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ctx = LocalContext.current
|
||||
// run actions on the main thread!
|
||||
val coroutineScope = rememberCoroutineScope { Dispatchers.Main }
|
||||
fun runActionAndDismiss(action: LongPressAction) {
|
||||
if (isLoading) {
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
action.action(ctx)
|
||||
} catch (_: CancellationException) {
|
||||
// the user canceled the action, e.g. by dismissing the dialog while loading
|
||||
} catch (t: Throwable) {
|
||||
ErrorUtil.showSnackbar(
|
||||
ctx,
|
||||
ErrorInfo(t, LONG_PRESS_MENU_ACTION, "Running action ${action.type}")
|
||||
)
|
||||
}
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
|
||||
// show a clickable uploader in the header if an uploader action is available and the
|
||||
// "show channel details" action is not enabled as a standalone action
|
||||
val onUploaderClick by remember {
|
||||
derivedStateOf {
|
||||
longPressActions.firstOrNull { it.type == ShowChannelDetails }
|
||||
?.takeIf { !actionArrangement.contains(ShowChannelDetails) }
|
||||
?.let { showChannelAction -> { runActionAndDismiss(showChannelAction) } }
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = onDismissRequest,
|
||||
dragHandle = { LongPressMenuDragHandle(onEditActions = { showEditor = true }) }
|
||||
) {
|
||||
Box(modifier = Modifier.discardAllTouchesIf(isLoading)) {
|
||||
LongPressMenuContent(
|
||||
header = longPressable.takeIf { isHeaderEnabled },
|
||||
onUploaderClick = onUploaderClick,
|
||||
actions = enabledLongPressActions,
|
||||
runActionAndDismiss = ::runActionAndDismiss
|
||||
)
|
||||
// importing makes the ColumnScope overload be resolved, so we use qualified path...
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = isLoading,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arranges the header and the buttons in a grid according to the following constraints:
|
||||
* - buttons have a minimum width, and all buttons should be exactly the same size
|
||||
* - as many buttons as possible should fit in a row, with no space between them, so misclicks can
|
||||
* still be caught and to leave more space for the button label text
|
||||
* - the header is exactly as large as `headerWidthInButtonsReducedSpan=4` buttons, but
|
||||
* `maxHeaderWidthInButtonsFullSpan=5` buttons wouldn't fit in a row then the header uses a full row
|
||||
* - if the header is not using a full row, then more buttons should fit with it on the same row,
|
||||
* so that the space is used efficiently e.g. in landscape or large screens
|
||||
* - the menu should be vertically scrollable if there are too many actions to fit on the screen
|
||||
*
|
||||
* Unfortunately all these requirements mean we can't simply use a [FlowRow] but have to build a
|
||||
* custom layout with [Row]s inside a [Column]. To make each item in the row have the appropriate
|
||||
* size, we use [androidx.compose.foundation.layout.RowScope.weight].
|
||||
*/
|
||||
@Composable
|
||||
private fun LongPressMenuContent(
|
||||
header: LongPressable?,
|
||||
@ -288,6 +327,7 @@ private fun LongPressMenuContent(
|
||||
modifier = Modifier
|
||||
.height(buttonHeight)
|
||||
.weight(1F)
|
||||
.testTag("LongPressMenuButton")
|
||||
)
|
||||
rowIndex += 1
|
||||
} else if (maxHeaderWidthInButtonsFullSpan >= buttonsPerRow) {
|
||||
@ -331,8 +371,12 @@ private fun LongPressMenuContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom [BottomSheetDefaults.DragHandle] that also shows a small button on the right, that opens
|
||||
* the long press menu settings editor.
|
||||
*/
|
||||
@Composable
|
||||
fun LongPressMenuDragHandle(onEditActions: () -> Unit) {
|
||||
private fun LongPressMenuDragHandle(onEditActions: () -> Unit) {
|
||||
var showFocusTrap by remember { mutableStateOf(true) }
|
||||
|
||||
Box(
|
||||
@ -358,7 +402,7 @@ fun LongPressMenuDragHandle(onEditActions: () -> Unit) {
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
|
||||
// show a small button here, it's not an important button and it shouldn't
|
||||
// show a small button to open the editor, it's not an important button and it shouldn't
|
||||
// capture the user attention
|
||||
TooltipIconButton(
|
||||
onClick = onEditActions,
|
||||
@ -384,8 +428,16 @@ private fun LongPressMenuDragHandlePreview() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A box that displays information about [item]: thumbnail, playlist item count, video duration,
|
||||
* title, channel, date, view count.
|
||||
*
|
||||
* @param item the item that was long pressed and whose info should be shown
|
||||
* @param onUploaderClick if not `null`, the [Text] containing the uploader will be made clickable
|
||||
* (even if `item.uploader` is `null`, in which case a placeholder uploader text will be shown)
|
||||
*/
|
||||
@Composable
|
||||
fun LongPressMenuHeader(
|
||||
private fun LongPressMenuHeader(
|
||||
item: LongPressable,
|
||||
onUploaderClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
@ -500,6 +552,7 @@ fun LongPressMenuHeader(
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
) {
|
||||
// title
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
@ -509,6 +562,8 @@ fun LongPressMenuHeader(
|
||||
.fadedMarquee(edgeWidth = 12.dp)
|
||||
)
|
||||
|
||||
// subtitle; see the javadocs of `getSubtitleAnnotatedString` and
|
||||
// `LongPressMenuHeaderSubtitle` to understand what is happening here
|
||||
val subtitle = getSubtitleAnnotatedString(
|
||||
item = item,
|
||||
showLink = onUploaderClick != null,
|
||||
@ -521,6 +576,7 @@ fun LongPressMenuHeader(
|
||||
if (onUploaderClick == null) {
|
||||
LongPressMenuHeaderSubtitle(subtitle)
|
||||
} else {
|
||||
// only show the tooltip if the menu is actually clickable
|
||||
val label = if (item.uploader != null) {
|
||||
stringResource(R.string.show_channel_details_for, item.uploader)
|
||||
} else {
|
||||
@ -541,6 +597,10 @@ fun LongPressMenuHeader(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Works in tandem with [getSubtitleAnnotatedString] and [getSubtitleInlineContent] to show the
|
||||
* subtitle line with a small material icon next to the uploader link.
|
||||
*/
|
||||
@Composable
|
||||
private fun LongPressMenuHeaderSubtitle(subtitle: AnnotatedString, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
@ -554,13 +614,21 @@ private fun LongPressMenuHeaderSubtitle(subtitle: AnnotatedString, modifier: Mod
|
||||
)
|
||||
}
|
||||
|
||||
fun getSubtitleAnnotatedString(
|
||||
/**
|
||||
* @param item information fields are from here and concatenated in a single string
|
||||
* @param showLink if true, a small material icon next to the uploader link; requires the [Text] to
|
||||
* use [getSubtitleInlineContent] later
|
||||
* @param linkColor which color to make the uploader link
|
||||
*/
|
||||
private fun getSubtitleAnnotatedString(
|
||||
item: LongPressable,
|
||||
showLink: Boolean,
|
||||
linkColor: Color,
|
||||
ctx: Context
|
||||
) = buildAnnotatedString {
|
||||
var shouldAddSeparator = false
|
||||
|
||||
// uploader (possibly with link)
|
||||
if (showLink) {
|
||||
withStyle(SpanStyle(color = linkColor)) {
|
||||
if (item.uploader.isNullOrBlank()) {
|
||||
@ -578,6 +646,7 @@ fun getSubtitleAnnotatedString(
|
||||
shouldAddSeparator = true
|
||||
}
|
||||
|
||||
// localized upload date
|
||||
val uploadDate = item.uploadDate?.match<String>(
|
||||
{ it },
|
||||
{ Localization.relativeTime(it) }
|
||||
@ -590,6 +659,7 @@ fun getSubtitleAnnotatedString(
|
||||
append(uploadDate)
|
||||
}
|
||||
|
||||
// localized view count
|
||||
val viewCount = item.viewCount?.let {
|
||||
Localization.localizeViewCount(ctx, true, item.streamType, it)
|
||||
}
|
||||
@ -606,7 +676,7 @@ fun getSubtitleAnnotatedString(
|
||||
* provide it to [Text] through its `inlineContent` parameter.
|
||||
*/
|
||||
@Composable
|
||||
fun getSubtitleInlineContent() = mapOf(
|
||||
private fun getSubtitleInlineContent() = mapOf(
|
||||
"open_in_new" to InlineTextContent(
|
||||
placeholder = Placeholder(
|
||||
width = MaterialTheme.typography.bodyMedium.fontSize,
|
||||
@ -622,8 +692,13 @@ fun getSubtitleInlineContent() = mapOf(
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* A button to show in the long press menu with an [icon] and a label [text]. When pressed,
|
||||
* [onClick] will be called, and when long pressed a tooltip will appear with the full [text]. If
|
||||
* the button should appear disabled, make sure to set [enabled]`=false`.
|
||||
*/
|
||||
@Composable
|
||||
fun LongPressMenuButton(
|
||||
private fun LongPressMenuButton(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
package org.schabi.newpipe.ui.components.menu
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.preference.PreferenceManager
|
||||
@ -11,20 +10,31 @@ import org.schabi.newpipe.App
|
||||
import org.schabi.newpipe.R
|
||||
|
||||
/**
|
||||
* Since view models can't have access to the UI's Context, we use [App.instance] instead to fetch
|
||||
* shared preferences. This is not the best but won't be needed anyway once we will have a Hilt
|
||||
* injected repository that provides access to a modern alternative to shared preferences. The whole
|
||||
* thing with the shared preference listener will not be necessary with the modern alternative.
|
||||
* Just handles loading preferences and listening for preference changes for [isHeaderEnabled] and
|
||||
* [actionArrangement].
|
||||
*
|
||||
* Note: Since view models can't have access to the UI's Context, we use [App.instance] instead to
|
||||
* fetch shared preferences. This won't be needed once we will have a Hilt injected repository that
|
||||
* provides access to a modern alternative to shared preferences. The whole thing with the shared
|
||||
* preference listener will not be necessary with the modern alternative.
|
||||
*/
|
||||
class LongPressMenuViewModel : ViewModel() {
|
||||
private val _isHeaderEnabled = MutableStateFlow(
|
||||
loadIsHeaderEnabledFromSettings(App.instance)
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether the user wants the header be shown in the long press menu.
|
||||
*/
|
||||
val isHeaderEnabled: StateFlow<Boolean> = _isHeaderEnabled.asStateFlow()
|
||||
|
||||
private val _actionArrangement = MutableStateFlow(
|
||||
loadLongPressActionArrangementFromSettings(App.instance)
|
||||
)
|
||||
|
||||
/**
|
||||
* The actions that the user wants to be shown (if they are applicable), and in which order.
|
||||
*/
|
||||
val actionArrangement: StateFlow<List<LongPressAction.Type>> = _actionArrangement.asStateFlow()
|
||||
|
||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(App.instance)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user