Skip to content

Commit a8a2b85

Browse files
authored
Change: Move bottom sheet actions to a header to keep them in view when keyboard is showing (#151)
1 parent 7a1011f commit a8a2b85

5 files changed

Lines changed: 237 additions & 185 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Abysner - Dive planner
3+
* Copyright (C) 2026 Neotech
4+
*
5+
* Abysner is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License version 3,
7+
* as published by the Free Software Foundation.
8+
*
9+
* You should have received a copy of the GNU Affero General Public License
10+
* along with this program. If not, see https://www.gnu.org/licenses/.
11+
*/
12+
13+
package org.neotech.app.abysner.presentation.component.bottomsheet
14+
15+
import androidx.compose.foundation.background
16+
import androidx.compose.foundation.layout.Box
17+
import androidx.compose.foundation.layout.Row
18+
import androidx.compose.foundation.layout.fillMaxWidth
19+
import androidx.compose.foundation.layout.height
20+
import androidx.compose.foundation.layout.padding
21+
import androidx.compose.foundation.layout.width
22+
import androidx.compose.foundation.shape.RoundedCornerShape
23+
import androidx.compose.material.icons.Icons
24+
import androidx.compose.material.icons.filled.Check
25+
import androidx.compose.material.icons.filled.Close
26+
import androidx.compose.material3.FilledIconButton
27+
import androidx.compose.material3.Icon
28+
import androidx.compose.material3.IconButton
29+
import androidx.compose.material3.MaterialTheme
30+
import androidx.compose.material3.Text
31+
import androidx.compose.runtime.Composable
32+
import androidx.compose.ui.Alignment
33+
import androidx.compose.ui.Modifier
34+
import androidx.compose.ui.draw.clip
35+
import androidx.compose.ui.text.style.TextAlign
36+
import androidx.compose.ui.unit.dp
37+
38+
@Composable
39+
fun BottomSheetHeader(
40+
modifier: Modifier = Modifier,
41+
title: String,
42+
primaryLabel: String,
43+
primaryEnabled: Boolean = true,
44+
onClose: () -> Unit,
45+
onPrimary: () -> Unit,
46+
) {
47+
Box(modifier) {
48+
Row(
49+
modifier = Modifier
50+
.fillMaxWidth()
51+
.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 16.dp),
52+
verticalAlignment = Alignment.CenterVertically,
53+
) {
54+
IconButton(onClick = onClose) {
55+
Icon(
56+
imageVector = Icons.Default.Close,
57+
contentDescription = "Dismiss",
58+
)
59+
}
60+
Text(
61+
modifier = Modifier.weight(1f),
62+
text = title,
63+
style = MaterialTheme.typography.titleLarge,
64+
textAlign = TextAlign.Center,
65+
)
66+
FilledIconButton(
67+
enabled = primaryEnabled,
68+
onClick = onPrimary,
69+
) {
70+
Icon(
71+
imageVector = Icons.Default.Check,
72+
contentDescription = primaryLabel,
73+
)
74+
}
75+
}
76+
Box(
77+
modifier = Modifier
78+
.fillMaxWidth()
79+
.padding(top = 8.dp),
80+
contentAlignment = Alignment.TopCenter,
81+
) {
82+
Box(
83+
modifier = Modifier
84+
.width(32.dp)
85+
.height(4.dp)
86+
.clip(RoundedCornerShape(50))
87+
.background(MaterialTheme.colorScheme.outlineVariant)
88+
)
89+
}
90+
}
91+
}

composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/component/bottomsheet/ModalBottomSheetScaffold.kt

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
22
* Abysner - Dive planner
3-
* Copyright (C) 2024 Neotech
3+
* Copyright (C) 2024-2026 Neotech
44
*
55
* Abysner is free software: you can redistribute it and/or modify
66
* it under the terms of the GNU Affero General Public License version 3,
@@ -14,6 +14,7 @@ package org.neotech.app.abysner.presentation.component.bottomsheet
1414

1515
import androidx.compose.foundation.layout.Box
1616
import androidx.compose.foundation.layout.BoxScope
17+
import androidx.compose.foundation.layout.Column
1718
import androidx.compose.foundation.rememberScrollState
1819
import androidx.compose.foundation.verticalScroll
1920
import androidx.compose.runtime.Composable
@@ -22,13 +23,14 @@ import androidx.compose.ui.Modifier
2223
@Composable
2324
fun ModalBottomSheetScaffold(
2425
modifier: Modifier = Modifier,
26+
header: @Composable () -> Unit = {},
2527
content: @Composable BoxScope.() -> Unit
2628
) {
2729
val scrollState = rememberScrollState()
28-
Box(
29-
Modifier
30-
.verticalScroll(scrollState).then(modifier)
31-
) {
32-
content()
30+
Column(modifier) {
31+
header()
32+
Box(Modifier.verticalScroll(scrollState)) {
33+
content()
34+
}
3335
}
3436
}

composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/cylinders/CylinderPickerBottomSheet.kt

Lines changed: 35 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ import org.neotech.app.abysner.domain.diveplanning.model.PlannedCylinderModel
5454
import org.neotech.app.abysner.presentation.utilities.ModalTarget
5555
import org.neotech.app.abysner.presentation.component.GasPickerComponent
5656
import org.neotech.app.abysner.presentation.component.GasPropertiesComponent
57-
import org.neotech.app.abysner.presentation.component.bottomsheet.BottomSheetButtonRow
57+
import org.neotech.app.abysner.presentation.component.bottomsheet.BottomSheetHeader
5858
import org.neotech.app.abysner.presentation.component.bottomsheet.ModalBottomSheetScaffold
5959
import org.neotech.app.abysner.presentation.component.clearFocusOutside
6060
import org.neotech.app.abysner.presentation.component.recordLayoutCoordinates
@@ -129,6 +129,7 @@ private fun CylinderPickerBottomSheet(
129129
onDismiss: () -> Unit = {},
130130
) {
131131
ModalBottomSheet(
132+
dragHandle = {},
132133
sheetState = sheetState,
133134
onDismissRequest = onDismiss,
134135
) {
@@ -179,34 +180,52 @@ private fun CylinderPickerBottomSheetContent(
179180
var volume: Double? by remember(initialValue) {
180181
mutableStateOf(initialValue.waterVolume)
181182
}
182-
val isVolumeValid = remember { mutableStateOf(false) }
183+
val isVolumeValid = remember { mutableStateOf(true) }
183184

184185
var startPressure: Double? by remember(initialValue) {
185186
mutableStateOf(initialValue.pressure)
186187
}
187-
val isStartPressureValid = remember { mutableStateOf(false) }
188+
val isStartPressureValid = remember { mutableStateOf(true) }
188189

189190
val textFieldPositions = mutableStateMapOf<String, LayoutCoordinates>()
190191

191192
val showStandardGasPickerDialog = remember { mutableStateOf(false) }
192193

194+
val dismiss: () -> Unit = {
195+
scope.launch {
196+
sheetState.hide()
197+
onDismissRequest()
198+
}
199+
}
200+
193201
ModalBottomSheetScaffold(
194-
modifier = Modifier
195-
.clearFocusOutside(textFieldPositions)
202+
modifier = Modifier.clearFocusOutside(textFieldPositions),
203+
header = {
204+
BottomSheetHeader(
205+
title = if (lockGas) { "Cylinder" } else { "Gas & cylinder" },
206+
primaryLabel = if (isAdd) { "Add" } else { "Update" },
207+
primaryEnabled = isVolumeValid.value && isStartPressureValid.value,
208+
onClose = dismiss,
209+
onPrimary = {
210+
onAddOrUpdateCylinder(
211+
// Copy since we want to maintain the uniqueIdentifier
212+
initialValue.copy(
213+
gas = Gas(
214+
oxygenFraction = oxygenPercentage / 100.0,
215+
heliumFraction = heliumPercentage / 100.0
216+
),
217+
pressure = startPressure!!,
218+
waterVolume = volume!!,
219+
)
220+
)
221+
dismiss()
222+
},
223+
)
224+
},
196225
) {
197226
Column(
198227
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
199-
) {
200-
201-
Text(
202-
modifier = Modifier
203-
.padding(bottom = 16.dp)
204-
.fillMaxWidth(),
205-
style = MaterialTheme.typography.headlineSmall,
206-
text = if (lockGas) { "Cylinder" } else { "Gas & cylinder" }
207-
)
208-
209-
val errorMessageVolume = remember { mutableStateOf<String?>(null) }
228+
) { val errorMessageVolume = remember { mutableStateOf<String?>(null) }
210229
val errorMessagePressure = remember { mutableStateOf<String?>(null) }
211230

212231
Row(
@@ -317,36 +336,6 @@ private fun CylinderPickerBottomSheetContent(
317336
}
318337
)
319338
}
320-
321-
BottomSheetButtonRow(
322-
modifier = Modifier.padding(top = 16.dp),
323-
secondaryLabel = "Cancel",
324-
primaryLabel = if (isAdd) { "Add" } else { "Update" },
325-
primaryEnabled = isVolumeValid.value,
326-
onSecondary = {
327-
scope.launch {
328-
sheetState.hide()
329-
onDismissRequest()
330-
}
331-
},
332-
onPrimary = {
333-
onAddOrUpdateCylinder(
334-
// Copy since we want to maintain the uniqueIdentifier
335-
initialValue.copy(
336-
gas = Gas(
337-
oxygenFraction = oxygenPercentage / 100.0,
338-
heliumFraction = heliumPercentage / 100.0
339-
),
340-
pressure = startPressure!!,
341-
waterVolume = volume!!,
342-
)
343-
)
344-
scope.launch {
345-
sheetState.hide()
346-
onDismissRequest()
347-
}
348-
},
349-
)
350339
}
351340
}
352341

composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/diveconfiguration/DiveConfigurationBottomSheet.kt

Lines changed: 43 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@
1212

1313
package org.neotech.app.abysner.presentation.screens.planner.diveconfiguration
1414

15-
import androidx.compose.foundation.layout.Arrangement
1615
import androidx.compose.foundation.layout.Column
17-
import androidx.compose.foundation.layout.Row
1816
import androidx.compose.foundation.layout.fillMaxWidth
1917
import androidx.compose.foundation.layout.padding
2018
import androidx.compose.material3.ButtonDefaults
@@ -33,7 +31,6 @@ import androidx.compose.runtime.mutableStateOf
3331
import androidx.compose.runtime.remember
3432
import androidx.compose.runtime.rememberCoroutineScope
3533
import androidx.compose.runtime.setValue
36-
import androidx.compose.ui.Alignment
3734
import androidx.compose.ui.Modifier
3835
import androidx.compose.ui.text.style.TextAlign
3936
import androidx.compose.ui.tooling.preview.Preview
@@ -44,7 +41,7 @@ import org.neotech.app.abysner.domain.core.model.DiveMode
4441
import org.neotech.app.abysner.domain.diveplanning.model.DivePlanInputModel
4542
import org.neotech.app.abysner.presentation.component.RadioCardGroup
4643
import org.neotech.app.abysner.presentation.component.RadioCardItem
47-
import org.neotech.app.abysner.presentation.component.bottomsheet.BottomSheetButtonRow
44+
import org.neotech.app.abysner.presentation.component.bottomsheet.BottomSheetHeader
4845
import org.neotech.app.abysner.presentation.component.bottomsheet.ModalBottomSheetScaffold
4946
import org.neotech.app.abysner.presentation.component.textfield.DurationInputField
5047
import org.neotech.app.abysner.presentation.utilities.ModalTarget
@@ -129,6 +126,7 @@ private fun DiveConfigurationBottomSheet(
129126
onDelete: (() -> Unit)? = null,
130127
) {
131128
ModalBottomSheet(
129+
dragHandle = {},
132130
sheetState = sheetState,
133131
onDismissRequest = onDismiss,
134132
) {
@@ -157,42 +155,40 @@ private fun DiveConfigurationBottomSheetContent(
157155
) {
158156
val scope = rememberCoroutineScope()
159157
var surfaceInterval: Duration? by remember(initialSurfaceInterval) { mutableStateOf(initialSurfaceInterval) }
160-
val isConfirmEnabled = remember(initialSurfaceInterval) { mutableStateOf(initialSurfaceInterval == null) }
158+
val isConfirmEnabled = remember(initialSurfaceInterval) { mutableStateOf(true) }
161159
var selectedDiveModeIndex by remember(initialDiveMode) { mutableIntStateOf(initialDiveMode.toSelectionIndex()) }
162160

163-
ModalBottomSheetScaffold {
161+
val dismiss: () -> Unit = {
162+
scope.launch {
163+
sheetState.hide()
164+
onDismiss()
165+
}
166+
}
167+
168+
ModalBottomSheetScaffold(
169+
header = {
170+
BottomSheetHeader(
171+
title = title,
172+
primaryLabel = "Confirm",
173+
primaryEnabled = isConfirmEnabled.value,
174+
onClose = dismiss,
175+
onPrimary = {
176+
val effectiveDuration = if (initialSurfaceInterval != null) {
177+
surfaceInterval ?: initialSurfaceInterval
178+
} else {
179+
null
180+
}
181+
onConfirm(effectiveDuration, selectedDiveModeIndex.toDiveMode())
182+
dismiss()
183+
},
184+
)
185+
},
186+
) {
164187
Column(
165188
modifier = Modifier
166189
.fillMaxWidth()
167190
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
168191
) {
169-
Row(
170-
modifier = Modifier
171-
.fillMaxWidth()
172-
.padding(bottom = 16.dp),
173-
horizontalArrangement = Arrangement.SpaceBetween,
174-
verticalAlignment = Alignment.CenterVertically,
175-
) {
176-
Text(
177-
style = MaterialTheme.typography.headlineSmall,
178-
text = title,
179-
)
180-
if (onDelete != null) {
181-
TextButton(
182-
onClick = {
183-
onDelete()
184-
scope.launch {
185-
sheetState.hide()
186-
onDismiss()
187-
}
188-
},
189-
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
190-
) {
191-
Text("Delete")
192-
}
193-
}
194-
}
195-
196192
RadioCardGroup(
197193
modifier = Modifier.padding(bottom = 16.dp),
198194
items = diveModeItems,
@@ -219,30 +215,20 @@ private fun DiveConfigurationBottomSheetContent(
219215
)
220216
}
221217

222-
BottomSheetButtonRow(
223-
modifier = Modifier.padding(top = 16.dp),
224-
secondaryLabel = "Cancel",
225-
primaryLabel = "Confirm",
226-
primaryEnabled = isConfirmEnabled.value,
227-
onSecondary = {
228-
scope.launch {
229-
sheetState.hide()
230-
onDismiss()
231-
}
232-
},
233-
onPrimary = {
234-
val effectiveDuration = if (initialSurfaceInterval != null) {
235-
surfaceInterval ?: initialSurfaceInterval
236-
} else {
237-
null
238-
}
239-
onConfirm(effectiveDuration, selectedDiveModeIndex.toDiveMode())
240-
scope.launch {
241-
sheetState.hide()
242-
onDismiss()
243-
}
244-
},
245-
)
218+
if (onDelete != null) {
219+
TextButton(
220+
modifier = Modifier
221+
.fillMaxWidth()
222+
.padding(top = 8.dp),
223+
onClick = {
224+
onDelete()
225+
dismiss()
226+
},
227+
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
228+
) {
229+
Text("Delete dive")
230+
}
231+
}
246232
}
247233
}
248234
}

0 commit comments

Comments
 (0)