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/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/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) + } } 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) + } }