diff --git a/feature/settings/README.md b/feature/settings/README.md index 29ab5cf13..e1bc9b6a7 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -20,7 +20,7 @@ Owns the unified Settings hub, category pages (Account, Sync & Backups, Appearan - `SettingsScreen`, `SettingsViewModel`, `SettingsViewModelAssembler`, and `ProfileSettingsDestination` for the settings hub and sub-pages. - Shared `SettingsScaffold` headers use a smaller 36sp expanded title and naturally wrap to two lines when needed, including Cloud Sync & Backups. The expanded title smoothly reduces to 22sp on scroll; the separate compact row always uses 22sp and retains two-line support for narrow screens and larger system fonts. Material's two-row app bar measures the title height, so a hidden expanded title cannot inflate the navigation row or force a single-line ellipsis. - `SettingsScreenConfig` includes `isOnboarding: Boolean` (used by app navigation to keep onboarding mode active and bypass the hub on return), `onSendFeedback: (() -> Unit)?` to route to feedback, and `resolveSettingsBackAction` for deterministic back-handler actions. -- `FeedbackScreen`, `FeedbackViewModel`, `DiagnosticCollector`, and `LogcatCollector` under `feedback/` for sticky draft feedback submissions, in-process sanitized logcat extraction, device diagnostics, and GitHub issue reporting. `FeedbackScreen(supportRepository, boxcastPrefs, onBack, bottomContentPadding)` takes `SupportRepository` from `:core:support` (app wiring passes the mini player clearance). +- `FeedbackScreen`, `FeedbackViewModel`, `DiagnosticCollector`, and `LogcatCollector` under `feedback/` for sticky draft feedback submissions, in-process sanitized logcat extraction, device diagnostics, and GitHub issue reporting. `FeedbackScreen(supportRepository, boxcastPrefs, onBack, bottomContentPadding)` takes `SupportRepository` from `:core:support` (app wiring passes the mini player clearance). Feedback keeps the player compact, so every pane (the form, a conversation, the sent screen and the buttons under the list) gives back the room the compact player frees as it docks beside the Floating navigation bar, never going below what the bar alone needs (`feedbackBottomSpace`, `feedbackChromePadding`, measured in layout so the docking animation doesn't recompose the page); Classic navigation and tablets keep the full clearance. The list's tail padding stays as passed, so it doesn't move while the player docks. - **Feedback, redesigned 2026-11-02 (support conversations, phase 2).** "Share your thoughts" opens straight on the form: bold circular type pills (`FeedbackTypePills`), a roomy message card that takes the free height, photos (only while the server's `meta.photos` switch is on; picked with the system photo picker, no storage permission, downscaled to 1600 px JPEG by `PhotoPrep`; the row scrolls sideways so every photo and its Remove stay reachable on narrow phones), an Include device diagnostics row, **Send feedback** at the bottom and a **How we handle feedback** link to the privacy page. The pills are the server's `meta.categories` (id and label, shown as sent); the app's own four (Feature idea, Bug report, Audio / Stream, General) are the offline list and give icons and hints for the ids they share (`FeedbackChoice`), and an id the server stops offering falls back to its first. The message limit is the server's `maxBody` as is; typing stops there, and an older draft past it (its steps now join the message) is kept whole and can only get shorter, with the counter in red and "This is over the …-character limit. Shorten it to send." until it fits (`limitedMessage`). The phone model goes only with diagnostics on. While a message is sending its draft is locked (field read-only, pills, switch and photos disabled), so what was sent is what gets cleared. Windows shorter than 600dp scroll with a 220dp message card. While typing (message focused and the keyboard up) the pills fold into one pill in the header and a slim bar with photos, diagnostics and **Send** rides on the keyboard, which replaces the bottom chrome padding (`isTypingLayout`, `feedbackBottomPadding`). It is one layout throughout: the keyboard or a height change only changes what sits around the message field, so the field keeps its focus, cursor and the text being composed. The top-bar menu has Preview diagnostics, Rate on Play, Report on GitHub and Discard draft. The email and steps fields are gone. Send is disabled while the server pauses new messages (`canStartNew`), with the server's notice or `PAUSED_NOTICE`; a refusal (today's limit) shows the server's words and keeps the draft. Each send carries a retry key (`RetryKeys`): sending the same message again reuses it, an edited message gets a new one, so a send whose answer was lost isn't saved twice. The key is kept in `boxlore_feedback_retry` (`SharedPrefsRetryKeyStore`, a fingerprint of the content and the key) until the message is saved or discarded, so it survives the screen closing or the app being killed; photos already uploaded for the message are reused on a retry. When support can't take photos (it is unavailable), the message goes without them. After sending, `FeedbackSentScreen` shows Echo's `feedback-sent` sequence (magnifier, then rest), "Feedback sent" and, when the message became a conversation and notifications are off, a **Know when the team replies** card (`canAskForNotifications`: Android's prompt while it will still show, otherwise the app's notification settings; re-checked on resume, **Not now** hides it). A message that fell back to the old one-way form (`SendResult.SentByEmailForm`) gets neither the reply promise nor the card, and left-out photos are mentioned (`feedbackSentBody`). - **Feedback conversations (support conversations, phase 3).** `FeedbackScreen(..., openThreadId)` opens on one conversation for a reply notification (`feedback?thread=`), on the list once the phone has conversations, and on "Share your thoughts" otherwise (`initialFeedbackPane`; the pane is saved with `FeedbackPaneSaver`). The list (`FeedbackListScreen`) shows "Your feedback" in one card, newest first: an unread dot and bold title for a new reply, the server's status label with its time ("11:02", "Yesterday", "28 Oct"; `feedbackListTime`), a check for Solved and a lock with a dimmed title for Closed. Rate on Play, Report on GitHub and **Send feedback** are pinned at the bottom with the list scrolling behind them; each **Send feedback** opens a fresh form (a new view-model key; the saved draft still comes back). After sending, the list refreshes at once (so the phone joins its reply-push topic before a reply can come), and Done goes to the list when the message became a conversation. A conversation (`FeedbackThreadScreen`, `FeedbackThreadViewModel`) shows the listener's messages in outlined cards and the team's in a primary-container card ("boxlore team"), with photos fetched through the support API into a small cache (12, one fetch per photo at a time, a failed one fetched again later) in a row that scrolls sideways, and "Diagnostics included" on its own line; **Show earlier messages** pages back. The conversation loads fresh each time it shows (its view model may be kept from earlier in the visit), reloads when a reply push arrives for it, and is marked read only once it is on screen, up to the newest message shown, so a reply that came after stays unread; that clears the list's dot and Home's indicator, and the list then refreshes its statuses. A failed refresh keeps what is shown with a "Couldn't refresh" line and Try again; a conversation that is gone clears. **Reply** opens a reply box that rides on the keyboard (the server's message limit, a retry key per reply, the draft kept on failure; the draft, whether the box is open and the retry key live in the view model's saved state, so they survive the process being killed); a "closed" answer shows its ending at once; **Mark solved** shows once the team has answered an open conversation. The server's ending card closes it: Solved (tertiary container, check, the server's words, Reply) or Closed (lock, the server's words, **Send new feedback**). Back walks conversation → list → out, and a new message goes back to the list when there is one (`feedbackBack`). On a landscape tablet with at least 840dp for Feedback (`feedbackUsesSplit`) the list (400dp) and the conversation sit side by side: the picked conversation, else the newest, else a new message, which then becomes the selection, so narrowing the window keeps it open; Back then leaves. The list and the conversation keep their places in one row in every window (`FeedbackPanesLayout`), so crossing the gate never rebuilds them: the conversation's scroll and the reply box's text, cursor and focus stay, and the host keeps the list's scroll position while it is hidden. A reply push refreshes the list while Feedback is open. A send that finishes after another form saved a new draft leaves that draft alone, and clears only its own retry key. - `AutoDownloadSettingsScreen` and `SmartDownloadsSettingsScreen` under `downloads/` for download management. @@ -183,7 +183,7 @@ src/main/java/cx/aswin/boxlore/feature/settings/ - `SupportPageLayoutTest` covers the arrangement thresholds (full stacked page, side by side, mission card cut-off, larger text) and renders the control and every world in 392×800 and 360×600 portrait and 800×392 landscape windows with no scrollable node, the pay button and tier info fully on screen, and the mission card shown only when it fits. - `SupportEntryImpressionTest` covers one impression per view, none for a configuration restore, a new one after returning, and the real Settings hub's top-bar surface. - Not covered by JVM tests: rendering quality on real devices and fold postures. Those need a phone, tablet or foldable check. -- Feedback: `FeedbackViewModelTest` covers draft restore (old steps joined), a draft past the limit kept whole until shortened, a server limit above 2,000, a conversation send (body trimmed, diagnostics, analytics without the text), the phone model only with diagnostics, the server's categories (an unknown id falls back, a new one is sent as is and counts as other in analytics), the old-form fallback without the reply promise, refusal and failure keeping the draft with a retry, blank messages, the draft locked while sending, photos ignored while switched off, de-duplicated picks up to the limit uploaded in order before the message, a failed or oversized photo sending nothing, photos left out when support can't take them, paused sending, retry keys (kept for a resend, new after an edit or a success, the same after the screen is rebuilt, with photos uploaded once), and Discard draft. `FeedbackLayoutPolicyTest` covers the typing-layout and keyboard-clearance rules, `limitedMessage`, when Android's notification prompt can still show, and the sent-screen copy. `FeedbackAdaptiveTransitionTest` (Robolectric) holds the view model outside composition, as the navigation entry does in the app, so it covers the screen's own state rather than the view model's lifetime. It keeps one live screen while the window crosses the 600dp width, 480dp height and 840dp split gates in both directions, portrait/landscape tablets, a short 800×360 window that scrolls, and a fold going flat → tabletop → flat, then recreates it (`StateRestorationTester`): the typed message, the picked type and a reachable, enabled Send survive, Send goes out once, and the sent screen keeps Done and the notifications card (or its dismissal) through the same walk; an old-form send never shows the card. With the keyboard played by passing its height in, the message field stays focused with its text as the keyboard opens and closes twice and the window goes short and tall, and typing continues where it was. On a 360dp phone all five photos and their Remove buttons are reachable. Not covered by JVM tests: a real keyboard's animation and composition, the photo picker, and Echo's motion; those need a phone and tablet check. Manual acceptance: on a phone, a tablet and a foldable, write a message, rotate and fold with the keyboard up and down, then send; the draft stays, the cursor stays in the field, Send stays reachable above the keyboard, and the sent screen offers notifications only while they are off. +- Feedback: `FeedbackViewModelTest` covers draft restore (old steps joined), a draft past the limit kept whole until shortened, a server limit above 2,000, a conversation send (body trimmed, diagnostics, analytics without the text), the phone model only with diagnostics, the server's categories (an unknown id falls back, a new one is sent as is and counts as other in analytics), the old-form fallback without the reply promise, refusal and failure keeping the draft with a retry, blank messages, the draft locked while sending, photos ignored while switched off, de-duplicated picks up to the limit uploaded in order before the message, a failed or oversized photo sending nothing, photos left out when support can't take them, paused sending, retry keys (kept for a resend, new after an edit or a success, the same after the screen is rebuilt, with photos uploaded once), and Discard draft. `FeedbackLayoutPolicyTest` covers the typing-layout and keyboard-clearance rules, the bottom space once the compact player has docked (Floating, not docked yet, nothing playing, Classic, a rail), `limitedMessage`, when Android's notification prompt can still show, and the sent-screen copy. `FeedbackAdaptiveTransitionTest` (Robolectric) holds the view model outside composition, as the navigation entry does in the app, so it covers the screen's own state rather than the view model's lifetime. It keeps one live screen while the window crosses the 600dp width, 480dp height and 840dp split gates in both directions, portrait/landscape tablets, a short 800×360 window that scrolls, and a fold going flat → tabletop → flat, then recreates it (`StateRestorationTester`): the typed message, the picked type and a reachable, enabled Send survive, Send goes out once, and the sent screen keeps Done and the notifications card (or its dismissal) through the same walk; an old-form send never shows the card. With the keyboard played by passing its height in, the message field stays focused with its text as the keyboard opens and closes twice and the window goes short and tall, and typing continues where it was. On a 360dp phone all five photos and their Remove buttons are reachable. Not covered by JVM tests: a real keyboard's animation and composition, the photo picker, and Echo's motion; those need a phone and tablet check. Manual acceptance: on a phone, a tablet and a foldable, write a message, rotate and fold with the keyboard up and down, then send; the draft stays, the cursor stays in the field, Send stays reachable above the keyboard, and the sent screen offers notifications only while they are off. - Feedback conversations: `FeedbackPanesTest` covers the opening pane, Back, the split gate (839/840dp, portrait, short), the saved pane and the times. `FeedbackThreadViewModelTest` covers marking an unread conversation read only once it shows and up to the newest message, gone and failed loads with a retry, earlier pages going first, a failed reply keeping its text and retry key, a refusal, a closed answer showing its ending at once even when the reload fails, gone clearing the screen, a push for this conversation reloading it (and one for another not), the reply draft outliving the view model through saved state, Mark solved, the reply limit, and a failed photo fetched again while a kept one isn't. `FeedbackConversationsAdaptiveTest` (Robolectric, the real `FeedbackScreen` on a fake support API) keeps the open conversation through phone ↔ split both ways across the 840dp gate, a portrait tablet, a short window and a foldable-sized window (no hinge data), and through recreation; a reply being written keeps its text and focus across the split gate both ways, and a conversation shown side by side stays open after narrowing; Back goes to the list, then out; side by side the newest conversation shows until another is picked and an unread one is marked read; a notification's id opens its conversation; and a phone without conversations opens on Share your thoughts. Not covered by JVM tests: real photos, the reply keyboard on a device, and push delivery. - Unit tests live under `feature/settings/src/test`. - `AccountPagePresentationTest` covers the onboarding and Settings layouts, picking each sign-in method and getting back to the cards, the check-email and confirm-email screens (resend cooldown), every sync state with its action, the Sync now label at 200% text in RTL, password-only Reset password, Echo press feedback, and that every Echo pose the page uses ships in the Echo manifest with a stage reach. `AccountPageAdaptiveTest` keeps one live page while the window moves phone → 599/600dp → tablet split → 479/480dp tall → portrait tablet → phone and checks that the chosen method, the typed email and the password mode survive, in both directions, and across activity recreation (`StateRestorationTester`). It also keeps a live syncing onboarding page across the same windows: the No need to wait note stays on screen above the tiles with Continue reachable, and finishing sync mid-resize turns all three tiles done and removes the note. Tests hold Echo on its resting pose with `LocalEchoMotion provides false` so the screen can go idle. App navigation tests cover hiding restored playback during Account and onboarding and restoring normal player presentation afterward. diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackBottomSpace.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackBottomSpace.kt new file mode 100644 index 000000000..2f6c24fd1 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackBottomSpace.kt @@ -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) + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt index 16f5b48d0..a41090f49 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt @@ -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), + ) } } } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackScreen.kt index 4636b8b08..93fcb9239 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackScreen.kt @@ -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 @@ -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, @@ -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) { diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt index 691dc1c4c..81cf8e8c7 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt @@ -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)) { diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadScreen.kt index e251f70e0..810eb3d52 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadScreen.kt @@ -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)) { diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackLayoutPolicyTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackLayoutPolicyTest.kt index 08a61ac3f..f5b7b06c5 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackLayoutPolicyTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackLayoutPolicyTest.kt @@ -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 @@ -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))