Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions feature/settings/README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package cx.aswin.boxlore.feature.settings.feedback

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.offset
import cx.aswin.boxlore.core.designsystem.component.LocalAdaptivePlayerCompactProgress
import cx.aswin.boxlore.core.designsystem.component.LocalNavigationStyle
import cx.aswin.boxlore.core.designsystem.component.NavigationStyle
import cx.aswin.boxlore.core.designsystem.component.appBottomChromeContentPadding
import cx.aswin.boxlore.core.designsystem.component.appBottomChromeOverlayOffset

/**
* [chrome] less the room the compact player gives back as it docks beside the navigation bar
* (Feedback keeps the player compact), never less than the navigation bar alone needs.
*/
internal fun feedbackChromePadding(chrome: Dp, navigationOnly: Dp, style: NavigationStyle, compactProgress: Float): Dp =
maxOf(minOf(chrome, navigationOnly), chrome - appBottomChromeOverlayOffset(style, isMiniPlayerVisible = true, compactProgress))

/**
* Space under a Feedback pane: the keyboard while it's up, otherwise the bottom chrome (as
* [feedbackChromePadding] leaves it) plus [extra]. Measured in layout, so the player's docking
* animation doesn't recompose the page.
*/
@Composable
internal fun Modifier.feedbackBottomSpace(chrome: Dp, extra: Dp, imeBottom: Dp = 0.dp): Modifier {
val style = LocalNavigationStyle.current
val progress = LocalAdaptivePlayerCompactProgress.current
val navigationOnly = appBottomChromeContentPadding(isMiniPlayerVisible = false)
return layout { measurable, constraints ->
val docked = feedbackChromePadding(chrome, navigationOnly, style, progress?.value ?: 0f)
val bottom = feedbackBottomPadding(imeBottom, docked, extra).roundToPx()
val child = measurable.measure(constraints.offset(vertical = -bottom))
layout(child.width, constraints.constrainHeight(child.height + bottom)) {
child.placeRelative(0, 0)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,12 @@ internal fun FeedbackListScreen(
loadFailed -> LoadFailed(actions.onRetry, Modifier.align(Alignment.Center).padding(bottom = bottom + LIST_ACTIONS_HEIGHT))
else -> CircularProgressIndicator(Modifier.align(Alignment.Center).padding(bottom = bottom + LIST_ACTIONS_HEIGHT))
}
ListActions(actions.onNew, actions.onRate, actions.onGitHub, Modifier.align(Alignment.BottomCenter).padding(start = 16.dp, end = 16.dp, bottom = bottom + 12.dp))
ListActions(
actions.onNew,
actions.onRate,
actions.onGitHub,
Modifier.align(Alignment.BottomCenter).feedbackBottomSpace(chrome = bottomContentPadding, extra = systemBar + 12.dp).padding(horizontal = 16.dp),
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
Expand Down Expand Up @@ -202,51 +203,19 @@ internal fun FeedbackComposeScreen(
ActivityResultContracts.PickMultipleVisualMedia(maxItems = state.meta.maxPhotos.coerceAtLeast(2)),
) { uris -> actions.onPhotosPicked(uris) }
val pickPhotos = { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }
val uriHandler = LocalUriHandler.current
val scroll = rememberScrollState()
val editable = !state.isSubmitting

Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
Box(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(bottom = feedbackBottomPadding(imeBottom, bottomContentPadding, systemBar)),
.feedbackBottomSpace(chrome = bottomContentPadding, extra = systemBar, imeBottom = imeBottom),
contentAlignment = Alignment.TopCenter,
) {
Column(Modifier.fillMaxSize().boxloreCardWidth(BoxloreCardFamily.Utility)) {
if (typing) TypingHeader(state, actions) else TopRow(state, actions)
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
// While typing the field takes the room the keyboard leaves; a short window otherwise scrolls.
val stretch = typing || maxHeight >= FEEDBACK_TALL_ENOUGH
Column(Modifier.fillMaxSize().then(if (stretch) Modifier else Modifier.verticalScroll(scroll))) {
if (!typing) Intro(state)
if (!typing) {
FeedbackTypePills(
choices = state.choices,
selected = state.choice,
enabled = editable,
onSelect = actions.onCategorySelected,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp),
)
}
FeedbackMessageCard(
value = state.message,
onValueChange = actions.onMessageChanged,
placeholder = state.choice.placeholder,
maxChars = state.meta.maxBody,
readOnly = !editable,
onFocusChanged = { messageFocused = it },
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = if (typing) 8.dp else 16.dp)
.fillMaxWidth()
.then(if (stretch) Modifier.weight(1f) else Modifier.height(FEEDBACK_SHORT_MESSAGE_HEIGHT)),
)
if (!typing) AttachRows(state, onAddPhoto = pickPhotos, actions = actions)
(state.errorMessage ?: state.limitMessage)?.let { error -> ErrorLine(error, small = typing) }
if (!typing) SendFooter(state, onSubmit = actions.onSubmit, onPrivacy = { uriHandler.openUri(FEEDBACK_PRIVACY_URL) })
}
}
ComposeFields(state, actions, typing, scroll, onMessageFocused = { messageFocused = it }, onAddPhoto = pickPhotos)
if (typing) {
FeedbackTypingBar(
state = state,
Expand All @@ -260,6 +229,50 @@ internal fun FeedbackComposeScreen(
}
}

/** Everything between the header and the typing bar. While typing the field takes the room the keyboard leaves; a short window otherwise scrolls. */
@Composable
private fun ColumnScope.ComposeFields(
state: FeedbackUiState,
actions: FeedbackComposeActions,
typing: Boolean,
scroll: ScrollState,
onMessageFocused: (Boolean) -> Unit,
onAddPhoto: () -> Unit,
) {
val uriHandler = LocalUriHandler.current
val editable = !state.isSubmitting
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
val stretch = typing || maxHeight >= FEEDBACK_TALL_ENOUGH
Column(Modifier.fillMaxSize().then(if (stretch) Modifier else Modifier.verticalScroll(scroll))) {
if (!typing) {
Intro(state)
FeedbackTypePills(
choices = state.choices,
selected = state.choice,
enabled = editable,
onSelect = actions.onCategorySelected,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp),
)
}
FeedbackMessageCard(
value = state.message,
onValueChange = actions.onMessageChanged,
placeholder = state.choice.placeholder,
maxChars = state.meta.maxBody,
readOnly = !editable,
onFocusChanged = onMessageFocused,
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = if (typing) 8.dp else 16.dp)
.fillMaxWidth()
.then(if (stretch) Modifier.weight(1f) else Modifier.height(FEEDBACK_SHORT_MESSAGE_HEIGHT)),
)
if (!typing) AttachRows(state, onAddPhoto = onAddPhoto, actions = actions)
(state.errorMessage ?: state.limitMessage)?.let { error -> ErrorLine(error, small = typing) }
if (!typing) SendFooter(state, onSubmit = actions.onSubmit, onPrivacy = { uriHandler.openUri(FEEDBACK_PRIVACY_URL) })
}
}
}

/** Photos (while the server allows them) and the diagnostics switch, under the message. */
@Composable
private fun AttachRows(state: FeedbackUiState, onAddPhoto: () -> Unit, actions: FeedbackComposeActions) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ internal fun FeedbackSentScreen(

Surface(modifier = modifier.fillMaxSize().testTag(FEEDBACK_SENT_TAG), color = MaterialTheme.colorScheme.surface) {
Box(
modifier = Modifier.fillMaxSize().statusBarsPadding().padding(bottom = bottomContentPadding + systemBar),
modifier = Modifier.fillMaxSize().statusBarsPadding().feedbackBottomSpace(chrome = bottomContentPadding, extra = systemBar),
contentAlignment = Alignment.TopCenter,
) {
Column(Modifier.fillMaxSize().boxloreCardWidth(BoxloreCardFamily.Utility)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ internal fun FeedbackThreadScreen(
val systemBar = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
Surface(modifier = modifier.fillMaxSize().testTag(FEEDBACK_THREAD_TAG), color = MaterialTheme.colorScheme.surface) {
Box(
Modifier.fillMaxSize().statusBarsPadding().padding(bottom = feedbackBottomPadding(imeBottom, bottomContentPadding, systemBar)),
Modifier.fillMaxSize().statusBarsPadding().feedbackBottomSpace(chrome = bottomContentPadding, extra = systemBar, imeBottom = imeBottom),
contentAlignment = Alignment.TopCenter,
) {
Column(Modifier.fillMaxSize().boxloreCardWidth(BoxloreCardFamily.Utility)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cx.aswin.boxlore.feature.settings.feedback

import androidx.compose.ui.unit.dp
import cx.aswin.boxlore.core.designsystem.component.NavigationStyle
import cx.aswin.boxlore.core.designsystem.component.appBottomChromeContentPadding
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
Expand All @@ -19,6 +21,18 @@ class FeedbackLayoutPolicyTest {
assertEquals(120.dp, feedbackBottomPadding(imeBottom = 0.dp, chrome = 96.dp, systemBar = 24.dp))
}

@Test fun `no empty strip above navigation once the compact player has docked beside it`() {
val navigationOnly = appBottomChromeContentPadding(NavigationStyle.Floating, isMiniPlayerVisible = false)
val withMiniPlayer = appBottomChromeContentPadding(NavigationStyle.Floating, isMiniPlayerVisible = true)
assertEquals(navigationOnly, feedbackChromePadding(withMiniPlayer, navigationOnly, NavigationStyle.Floating, compactProgress = 1f))
assertEquals("Not docked yet: the mini player still needs its room", withMiniPlayer, feedbackChromePadding(withMiniPlayer, navigationOnly, NavigationStyle.Floating, compactProgress = 0f))
assertEquals("Nothing playing: never less than navigation needs", navigationOnly, feedbackChromePadding(navigationOnly, navigationOnly, NavigationStyle.Floating, compactProgress = 1f))
val classicNavigation = appBottomChromeContentPadding(NavigationStyle.Classic, isMiniPlayerVisible = false)
val classicPlayer = appBottomChromeContentPadding(NavigationStyle.Classic, isMiniPlayerVisible = true)
assertEquals("Classic keeps the full mini player", classicPlayer, feedbackChromePadding(classicPlayer, classicNavigation, NavigationStyle.Classic, compactProgress = 1f))
assertEquals("A navigation rail leaves the docked player's room alone", 96.dp, feedbackChromePadding(96.dp, 0.dp, NavigationStyle.Floating, compactProgress = 0f))
}

@Test fun `Android's prompt is asked for only while it will still show`() {
assertTrue(canAskForNotifications(sdkInt = 33, granted = false, askedBefore = false, shouldShowRationale = false))
assertTrue(canAskForNotifications(sdkInt = 33, granted = false, askedBefore = true, shouldShowRationale = true))
Expand Down
Loading