@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.imePadding
1616import androidx.compose.foundation.layout.padding
1717import androidx.compose.foundation.layout.size
1818import androidx.compose.foundation.layout.width
19+ import androidx.compose.foundation.gestures.detectTapGestures
1920import androidx.compose.foundation.rememberScrollState
2021import androidx.compose.foundation.shape.RoundedCornerShape
2122import androidx.compose.foundation.text.BasicTextField
@@ -63,12 +64,16 @@ import androidx.compose.runtime.mutableFloatStateOf
6364import androidx.compose.runtime.mutableIntStateOf
6465import androidx.compose.runtime.mutableStateOf
6566import androidx.compose.runtime.remember
67+ import androidx.compose.runtime.rememberCoroutineScope
68+ import androidx.compose.runtime.rememberUpdatedState
6669import androidx.compose.runtime.setValue
6770import androidx.compose.ui.Alignment
6871import androidx.compose.ui.Modifier
6972import androidx.compose.ui.draw.alpha
7073import androidx.compose.ui.focus.onFocusChanged
7174import androidx.compose.ui.graphics.SolidColor
75+ import androidx.compose.ui.input.pointer.pointerInput
76+ import kotlinx.coroutines.launch
7277import androidx.compose.ui.res.stringResource
7378import androidx.compose.ui.text.TextStyle
7479import androidx.compose.ui.text.input.KeyboardType
@@ -740,7 +745,7 @@ private fun AlarmRuleEditorDialog(
740745 NumberUpDown (
741746 value = cooldownSeconds,
742747 onValueChange = { cooldownSeconds = it },
743- range = 1 .. 120 ,
748+ range = 0 .. 120 ,
744749 suffix = " s" ,
745750 label = stringResource(R .string.alarm_cooldown_label),
746751 modifier = Modifier .weight(1f ),
@@ -772,10 +777,19 @@ private fun AlarmRuleEditorDialog(
772777 }
773778 }
774779 HintText (
775- if (repeatWhileActive)
776- stringResource(R .string.alarm_repeat_help_many, cooldownSeconds, triggeredPhrase)
777- else
778- stringResource(R .string.alarm_repeat_help_once, triggeredPhrase, safePhrase, cooldownSeconds),
780+ when {
781+ // Cooldown 0 = no rate limit. In Many mode that means a
782+ // fresh alert on every telemetry reading while held; in
783+ // Once mode it re-arms the instant the value goes safe.
784+ repeatWhileActive && cooldownSeconds == 0 ->
785+ stringResource(R .string.alarm_repeat_help_many_instant, triggeredPhrase)
786+ repeatWhileActive ->
787+ stringResource(R .string.alarm_repeat_help_many, cooldownSeconds, triggeredPhrase)
788+ cooldownSeconds == 0 ->
789+ stringResource(R .string.alarm_repeat_help_once_instant, triggeredPhrase, safePhrase)
790+ else ->
791+ stringResource(R .string.alarm_repeat_help_once, triggeredPhrase, safePhrase, cooldownSeconds)
792+ },
779793 small = true
780794 )
781795
@@ -924,14 +938,28 @@ internal fun NumberUpDown(
924938 suffix : String = "",
925939 label : String? = null,
926940 enabled : Boolean = true,
941+ // Decimal / signed support: the value stays an Int (the caller scales it,
942+ // e.g. tenths of a percent), [format] renders it for display and [parse]
943+ // turns typed text back into that Int. Defaults keep plain integer behaviour.
944+ format : (Int ) -> String = { it.toString() },
945+ parse : (String ) -> Int? = { it.toIntOrNull() },
946+ allowSign : Boolean = false,
927947) {
928948 val fieldText = MaterialTheme .appColors.fieldText
929949 val fieldLabelColor = MaterialTheme .appColors.fieldLabel
930- var text by remember { mutableStateOf(value.toString( )) }
950+ var text by remember { mutableStateOf(format(value )) }
931951 var focused by remember { mutableStateOf(false ) }
932952 // Width the typed number to the widest value in range so the unit stays put
933953 // and the digits + unit read as one centred group.
934- val numWidth = (maxOf(2 , range.first.toString().length, range.last.toString().length) * 12 ).dp
954+ val numWidth = (maxOf(2 , format(range.first).length, format(range.last).length) * 12 ).dp
955+
956+ // rememberUpdatedState so the hold-to-repeat loop below always steps from the
957+ // freshly committed value, not the value captured when the press started.
958+ val latestValue by rememberUpdatedState(value)
959+ fun stepBy (delta : Int ) {
960+ val nv = (latestValue + delta).coerceIn(range)
961+ if (nv != latestValue) { text = format(nv); onValueChange(nv) }
962+ }
935963
936964 Column (modifier = modifier.alpha(if (enabled) 1f else 0.5f )) {
937965 if (label != null ) {
@@ -952,32 +980,26 @@ internal fun NumberUpDown(
952980 modifier = Modifier .height(48 .dp),
953981 verticalAlignment = Alignment .CenterVertically
954982 ) {
955- IconButton (
956- onClick = {
957- val nv = (value - step).coerceIn(range)
958- text = nv.toString(); onValueChange(nv)
959- },
983+ RepeatingStepper (
984+ icon = Icons .Default .Remove ,
985+ contentDescription = stringResource(R .string.alarm_threshold_decrease),
960986 enabled = enabled && value > range.first,
961- modifier = Modifier .size(48 .dp)
962- ) {
963- Icon (
964- Icons .Default .Remove ,
965- contentDescription = stringResource(R .string.alarm_threshold_decrease),
966- tint = fieldText,
967- modifier = Modifier .size(20 .dp)
968- )
969- }
987+ tint = fieldText,
988+ onStep = { stepBy(- step) },
989+ )
970990 Row (
971991 modifier = Modifier .weight(1f ),
972992 horizontalArrangement = Arrangement .Center ,
973993 verticalAlignment = Alignment .CenterVertically
974994 ) {
975995 BasicTextField (
976- value = if (focused) text else value.toString( ),
996+ value = if (focused) text else format(value ),
977997 onValueChange = { raw ->
978- val digits = raw.filter { it.isDigit() }.take(6 )
979- text = digits
980- digits.toIntOrNull()?.let { if (it in range) onValueChange(it) }
998+ val filtered = if (allowSign)
999+ raw.filter { it.isDigit() || it == ' -' || it == ' .' }.take(7 )
1000+ else raw.filter { it.isDigit() }.take(6 )
1001+ text = filtered
1002+ parse(filtered)?.let { if (it in range) onValueChange(it) }
9811003 },
9821004 singleLine = true ,
9831005 enabled = enabled,
@@ -988,11 +1010,13 @@ internal fun NumberUpDown(
9881010 textAlign = TextAlign .Center
9891011 ),
9901012 cursorBrush = SolidColor (fieldText),
991- keyboardOptions = KeyboardOptions (keyboardType = KeyboardType .Number ),
1013+ keyboardOptions = KeyboardOptions (
1014+ keyboardType = if (allowSign) KeyboardType .Decimal else KeyboardType .Number
1015+ ),
9921016 modifier = Modifier
9931017 .width(numWidth)
9941018 .onFocusChanged { f ->
995- if (f.isFocused) text = value.toString( )
1019+ if (f.isFocused) text = format(value )
9961020 focused = f.isFocused
9971021 }
9981022 )
@@ -1001,26 +1025,63 @@ internal fun NumberUpDown(
10011025 Text (suffix, fontSize = 14 .sp, color = fieldLabelColor)
10021026 }
10031027 }
1004- IconButton (
1005- onClick = {
1006- val nv = (value + step).coerceIn(range)
1007- text = nv.toString(); onValueChange(nv)
1008- },
1028+ RepeatingStepper (
1029+ icon = Icons .Default .Add ,
1030+ contentDescription = stringResource(R .string.alarm_threshold_increase),
10091031 enabled = enabled && value < range.last,
1010- modifier = Modifier .size(48 .dp)
1011- ) {
1012- Icon (
1013- Icons .Default .Add ,
1014- contentDescription = stringResource(R .string.alarm_threshold_increase),
1015- tint = fieldText,
1016- modifier = Modifier .size(20 .dp)
1017- )
1018- }
1032+ tint = fieldText,
1033+ onStep = { stepBy(step) },
1034+ )
10191035 }
10201036 }
10211037 }
10221038}
10231039
1040+ /* *
1041+ * 48dp icon button for the NumberUpDown steppers: fires [onStep] once on press,
1042+ * then auto-repeats with acceleration while held so the rider can sweep a value
1043+ * fast (handy for fine 0.1-step fields like speed calibration).
1044+ */
1045+ @Composable
1046+ private fun RepeatingStepper (
1047+ icon : androidx.compose.ui.graphics.vector.ImageVector ,
1048+ contentDescription : String ,
1049+ enabled : Boolean ,
1050+ tint : androidx.compose.ui.graphics.Color ,
1051+ onStep : () -> Unit ,
1052+ ) {
1053+ val step by rememberUpdatedState(onStep)
1054+ val scope = rememberCoroutineScope()
1055+ Box (
1056+ modifier = Modifier
1057+ .size(48 .dp)
1058+ .pointerInput(enabled) {
1059+ if (! enabled) return @pointerInput
1060+ detectTapGestures(onPress = {
1061+ step() // immediate first tick
1062+ val job = scope.launch {
1063+ kotlinx.coroutines.delay(400 ) // hold before auto-repeat
1064+ var d = 260L
1065+ while (true ) {
1066+ step()
1067+ kotlinx.coroutines.delay(d)
1068+ d = (d * 80 / 100 ).coerceAtLeast(40L ) // accelerate, floor 40ms
1069+ }
1070+ }
1071+ try { awaitRelease() } finally { job.cancel() }
1072+ })
1073+ },
1074+ contentAlignment = Alignment .Center
1075+ ) {
1076+ Icon (
1077+ icon,
1078+ contentDescription = contentDescription,
1079+ tint = if (enabled) tint else tint.copy(alpha = 0.3f ),
1080+ modifier = Modifier .size(20 .dp)
1081+ )
1082+ }
1083+ }
1084+
10241085@OptIn(ExperimentalMaterial3Api ::class )
10251086@Composable
10261087private fun DropdownSelect (
0 commit comments