From bf7016b4c59ecbda2cbdac2be6faa110eef0f315 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Fri, 2 Oct 2026 22:54:05 -0600 Subject: [PATCH 1/2] Dismiss tracking edit sheet only from its drag handle #20 fixed EditTimeEntryDialog, used by Calendar and Inbox, but the Tracking screen edits entries in its own TimeEntryFormSheet, which still let downward swipes inside the form dismiss the sheet. Apply the same ConsumeVerticalOverscroll connection there and cover it with a test. --- .../ui/tracking/TrackingScreen.kt | 3 ++ .../ui/tracking/TimeEntryFormSheetTest.kt | 35 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt index afdfc0b9..19a0afc8 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt @@ -163,6 +163,7 @@ import dev.tricked.solidverdant.service.TimeTrackingNotificationService import dev.tricked.solidverdant.ui.components.AppSheet import dev.tricked.solidverdant.ui.components.AppTimePickerDialog import dev.tricked.solidverdant.ui.components.ConfirmDialog +import dev.tricked.solidverdant.ui.components.ConsumeVerticalOverscroll import dev.tricked.solidverdant.ui.components.DateRangePickerDialog import dev.tricked.solidverdant.ui.components.DestructiveActionRow import dev.tricked.solidverdant.ui.components.EditTimeEntryTestTags @@ -1953,6 +1954,8 @@ internal fun TimeEntryFormSheet( Column( modifier = Modifier .fillMaxWidth() + // Only the drag handle may dismiss the sheet; swipes in the form just scroll it. + .nestedScroll(ConsumeVerticalOverscroll) .verticalScroll(rememberScrollState()) .navigationBarsPadding() .imePadding() diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TimeEntryFormSheetTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TimeEntryFormSheetTest.kt index e3dac0ee..f13df3bb 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TimeEntryFormSheetTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TimeEntryFormSheetTest.kt @@ -16,6 +16,8 @@ import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown import dev.tricked.solidverdant.data.model.TimeEntry import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -115,4 +117,37 @@ class TimeEntryFormSheetTest { composeRule.onNodeWithTag(TrackingTestTags.SHEET_SAVE_BUTTON).performClick() assertEquals("Running work", saved) } + + @Test + fun swiping_down_inside_the_form_does_not_dismiss_the_sheet() { + var dismissCount = 0 + composeRule.setContent { + MaterialTheme { + TimeEntryFormSheet( + entry = TimeEntry( + id = "completed", + userId = "user", + organizationId = "org", + start = "2026-08-21T08:00:00Z", + end = "2026-08-21T09:00:00Z", + description = "Milling", + ), + zone = ZoneId.of("UTC"), + suggestedStart = null, + projects = emptyList(), + tasks = emptyList(), + tags = emptyList(), + onDismiss = { dismissCount++ }, + onSave = { _, _, _, _, _, _, _ -> }, + ) + } + } + + // The form is already scrolled to the top, so the whole swipe is leftover the sheet could take. + composeRule.onNodeWithTag(TrackingTestTags.SHEET_DESCRIPTION_FIELD).performTouchInput { swipeDown(durationMillis = 100) } + composeRule.waitForIdle() + + composeRule.onNodeWithTag(TrackingTestTags.SHEET).assertExists() + assertEquals(0, dismissCount) + } } From 50765dce925b5d6a458ca7e86f450661077307d5 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Fri, 2 Oct 2026 23:11:27 -0600 Subject: [PATCH 2/2] Dismiss every bottom sheet only from its drag handle Apply ConsumeVerticalOverscroll to the shared AppSheet and to the server, template editor and statistics drill-down sheets, so a downward swipe inside any sheet's content only scrolls it. Each sheet gets a test that fails without the change. --- .../solidverdant/ui/components/AppDialogs.kt | 3 +++ .../solidverdant/ui/config/ConfigScreen.kt | 4 ++++ .../ui/statistics/StatDrillDownSheet.kt | 9 ++++++++- .../ui/templates/TemplateEditorSheet.kt | 4 ++++ .../ui/components/SheetSwipeDismissTest.kt | 19 +++++++++++++++++++ .../ui/config/ConfigScreenPasteTest.kt | 16 +++++++++++++++- .../ui/statistics/StatDrillDownSheetTest.kt | 17 ++++++++++++++++- .../ui/templates/TemplateEditorSheetTest.kt | 16 +++++++++++++++- 8 files changed, 84 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/components/AppDialogs.kt b/app/src/main/java/dev/tricked/solidverdant/ui/components/AppDialogs.kt index c874ec85..187fb154 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/components/AppDialogs.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/components/AppDialogs.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -68,6 +69,8 @@ fun AppSheet( Column( modifier = Modifier .fillMaxWidth() + // Only the drag handle may dismiss the sheet; swipes in the content just scroll it. + .nestedScroll(ConsumeVerticalOverscroll) .verticalScroll(rememberScrollState()) .navigationBarsPadding() .imePadding() diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/config/ConfigScreen.kt b/app/src/main/java/dev/tricked/solidverdant/ui/config/ConfigScreen.kt index 0bece8a6..4fdb4831 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/config/ConfigScreen.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/config/ConfigScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -57,6 +58,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import dev.tricked.solidverdant.R import dev.tricked.solidverdant.ui.auth.OAuthConfigState +import dev.tricked.solidverdant.ui.components.ConsumeVerticalOverscroll import dev.tricked.solidverdant.ui.components.EntrySheetHeader import dev.tricked.solidverdant.ui.components.GroupedDivider import dev.tricked.solidverdant.ui.components.GroupedRow @@ -128,6 +130,8 @@ fun ConfigScreen( Column( modifier = Modifier .fillMaxWidth() + // Only the drag handle may dismiss the sheet; swipes in the content just scroll it. + .nestedScroll(ConsumeVerticalOverscroll) .verticalScroll(rememberScrollState()) .navigationBarsPadding() .imePadding() diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatDrillDownSheet.kt b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatDrillDownSheet.kt index a6c29f99..b7410f97 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatDrillDownSheet.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatDrillDownSheet.kt @@ -27,12 +27,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import dev.tricked.solidverdant.R +import dev.tricked.solidverdant.ui.components.ConsumeVerticalOverscroll import dev.tricked.solidverdant.ui.components.EmptyState import dev.tricked.solidverdant.ui.components.ErrorState import dev.tricked.solidverdant.ui.components.GroupedPosition @@ -85,7 +87,12 @@ fun StatDrillDownSheet(state: DrillDownUiState, onDismiss: () -> Unit, onRetry: containerColor = MaterialTheme.colorScheme.background, ) { LazyColumn( - modifier = Modifier.fillMaxWidth().navigationBarsPadding().testTag(StatDrillDownTestTags.LIST), + modifier = Modifier + .fillMaxWidth() + // Only the drag handle may dismiss the sheet; swipes in the content just scroll it. + .nestedScroll(ConsumeVerticalOverscroll) + .navigationBarsPadding() + .testTag(StatDrillDownTestTags.LIST), contentPadding = PaddingValues(bottom = Dimens.Space24), ) { item(key = "title") { diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/templates/TemplateEditorSheet.kt b/app/src/main/java/dev/tricked/solidverdant/ui/templates/TemplateEditorSheet.kt index 3ab810b4..2a82c440 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/templates/TemplateEditorSheet.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/templates/TemplateEditorSheet.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription @@ -43,6 +44,7 @@ import dev.tricked.solidverdant.data.model.Tag import dev.tricked.solidverdant.data.model.Task import dev.tricked.solidverdant.data.repository.EntryTemplate import dev.tricked.solidverdant.ui.components.ConfirmDialog +import dev.tricked.solidverdant.ui.components.ConsumeVerticalOverscroll import dev.tricked.solidverdant.ui.components.DestructiveActionRow import dev.tricked.solidverdant.ui.components.EntrySheetHeader import dev.tricked.solidverdant.ui.components.GroupedDivider @@ -142,6 +144,8 @@ fun TemplateEditorSheet( Column( modifier = Modifier .fillMaxWidth() + // Only the drag handle may dismiss the sheet; swipes in the content just scroll it. + .nestedScroll(ConsumeVerticalOverscroll) .verticalScroll(rememberScrollState()) .navigationBarsPadding() .imePadding() diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/components/SheetSwipeDismissTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/components/SheetSwipeDismissTest.kt index de9715ef..6e9d4843 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/components/SheetSwipeDismissTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/components/SheetSwipeDismissTest.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -99,4 +100,22 @@ class SheetSwipeDismissTest { private companion object { const val FORM = "sheet_form" } + + @Test + fun swiping_down_inside_an_app_sheet_does_not_dismiss_it() { + var dismissed = 0 + composeRule.setContent { + MaterialTheme { + AppSheet(title = "Options", onDismiss = { dismissed++ }) { + Text("Body", Modifier.fillMaxWidth().height(200.dp).testTag("body")) + } + } + } + + composeRule.onNodeWithTag("body").performTouchInput { swipeDown(durationMillis = 100) } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("body").assertExists() + assertEquals(0, dismissed) + } } diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/config/ConfigScreenPasteTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/config/ConfigScreenPasteTest.kt index 92aeb7c0..cdc8a03c 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/config/ConfigScreenPasteTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/config/ConfigScreenPasteTest.kt @@ -18,6 +18,8 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown import androidx.lifecycle.Lifecycle import dev.tricked.solidverdant.ui.auth.OAuthConfigState import org.junit.Assert.assertEquals @@ -36,6 +38,7 @@ class ConfigScreenPasteTest { val composeRule = createAndroidComposeRule() private var saved: Pair? = null + private var dismissed = 0 private fun showSheetWithClipboard(text: String) { val clipboard = composeRule.activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager @@ -47,7 +50,7 @@ class ConfigScreenPasteTest { onSave = { endpoint, clientId -> saved = endpoint to clientId }, onReset = {}, onTestConnection = { _, _ -> }, - onDismiss = {}, + onDismiss = { dismissed++ }, ) } } @@ -91,6 +94,17 @@ class ConfigScreenPasteTest { composeRule.onNodeWithTag(ConfigTestTags.CLIENT_ID_FIELD).assert(hasText("saved-client")) } + @Test + fun swiping_down_inside_the_form_does_not_dismiss_the_sheet() { + showSheetWithClipboard("https://clip.example:$CLIP_CLIENT") + + composeRule.onNodeWithTag(ConfigTestTags.ENDPOINT_FIELD).performTouchInput { swipeDown(durationMillis = 100) } + composeRule.waitForIdle() + + composeRule.onNodeWithTag(ConfigTestTags.ENDPOINT_FIELD).assertExists() + assertEquals(0, dismissed) + } + private companion object { const val CLIP_CLIENT = "9f3c2a71-5d1e-4c9b-a0f2-1b7e6d4c8a90" } diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatDrillDownSheetTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatDrillDownSheetTest.kt index ebd25900..d3132475 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatDrillDownSheetTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatDrillDownSheetTest.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -42,9 +44,11 @@ class StatDrillDownSheetTest { billable = index % 2 == 0, ) + private var dismissed = 0 + private fun show(state: DrillDownUiState) { composeRule.setContent { - MaterialTheme { StatDrillDownSheet(state = state, onDismiss = {}) } + MaterialTheme { StatDrillDownSheet(state = state, onDismiss = { dismissed++ }) } } } @@ -113,4 +117,15 @@ class StatDrillDownSheetTest { assertEquals(1, retries) composeRule.onNodeWithTag(StatDrillDownTestTags.row("e0")).assertExists() } + + @Test + fun swipingDownInsideTheListDoesNotDismissTheSheet() { + show(DrillDownUiState(target = target, isLoading = false, rows = listOf(row(0)), totalSeconds = 600)) + + composeRule.onNodeWithTag(StatDrillDownTestTags.LIST).performTouchInput { swipeDown(durationMillis = 100) } + composeRule.waitForIdle() + + composeRule.onNodeWithTag(StatDrillDownTestTags.SHEET).assertExists() + assertEquals(0, dismissed) + } } diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/templates/TemplateEditorSheetTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/templates/TemplateEditorSheetTest.kt index 612e14aa..940110b2 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/templates/TemplateEditorSheetTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/templates/TemplateEditorSheetTest.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown import dev.tricked.solidverdant.data.model.Tag import dev.tricked.solidverdant.data.repository.EntryTemplate import dev.tricked.solidverdant.ui.components.EditTimeEntryTestTags @@ -38,6 +40,7 @@ class TemplateEditorSheetTest { private var saved: TemplateDraft? = null private var updated: EntryTemplate? = null private var deleted = 0 + private var dismissed = 0 private fun show(template: EntryTemplate?, deletable: Boolean = true) { composeRule.setContent { @@ -47,7 +50,7 @@ class TemplateEditorSheetTest { projects = emptyList(), tasks = emptyList(), tags = tags, - onDismiss = {}, + onDismiss = { dismissed++ }, onSave = { saved = it }, onUpdate = { updated = it }, onDelete = if (deletable) ({ deleted++ }) else null, @@ -112,4 +115,15 @@ class TemplateEditorSheetTest { composeRule.onNodeWithTag(TemplateEditorTestTags.DELETE).assertDoesNotExist() } + + @Test + fun swipingDownInsideTheFormDoesNotDismissTheSheet() { + show(template = existing) + + composeRule.onNodeWithTag(TemplateEditorTestTags.NAME).performTouchInput { swipeDown(durationMillis = 100) } + composeRule.waitForIdle() + + composeRule.onNodeWithTag(TemplateEditorTestTags.SHEET).assertExists() + assertEquals(0, dismissed) + } }