diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml
index c485118f..d8afb58b 100644
--- a/shared/src/commonMain/composeResources/values/strings.xml
+++ b/shared/src/commonMain/composeResources/values/strings.xml
@@ -250,6 +250,12 @@
In process
Completed
+ Priority
+ Not specified
+ Low
+ Medium
+ High
+
Not authorized. Please check your server, username and password.
Welcome to %1$s
diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/Priority.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/Priority.kt
new file mode 100644
index 00000000..10de9774
--- /dev/null
+++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/Priority.kt
@@ -0,0 +1,37 @@
+package at.techbee.spectacled.screens.core.domain
+
+import org.jetbrains.compose.resources.StringResource
+import spectacled.shared.generated.resources.Res
+import spectacled.shared.generated.resources.priority_high
+import spectacled.shared.generated.resources.priority_low
+import spectacled.shared.generated.resources.priority_medium
+import spectacled.shared.generated.resources.priority_not_specified
+
+/**
+ * The coarse priority levels offered in the UI, ordered from lowest to highest so that
+ * [ordinal] can be used directly as a slider step.
+ *
+ * RFC 5545 (3.8.1.9) defines PRIORITY as 0 (undefined) or 1 (highest) to 9 (lowest) and groups
+ * 1-4 as high, 5 as medium and 6-9 as low. [icsValue] is the value written when a level is
+ * picked; [fromIcsValue] maps any stored value back to its level.
+ */
+enum class Priority(
+ val icsValue: Long?,
+ val stringRes: StringResource
+) {
+ NOT_SPECIFIED(null, Res.string.priority_not_specified),
+ LOW(9L, Res.string.priority_low),
+ MEDIUM(5L, Res.string.priority_medium),
+ HIGH(1L, Res.string.priority_high);
+
+ companion object {
+ fun fromIcsValue(icsValue: Long?): Priority = when (icsValue) {
+ in 1L..4L -> HIGH
+ 5L -> MEDIUM
+ in 6L..9L -> LOW
+ else -> NOT_SPECIFIED // null, 0 (undefined per RFC 5545) and out-of-range values
+ }
+
+ fun fromSliderStep(step: Int): Priority = entries.getOrElse(step) { NOT_SPECIFIED }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/presentation/components/StatusWithProgressIcon.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/presentation/components/StatusWithProgressIcon.kt
index 8b117455..0fe6847f 100644
--- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/presentation/components/StatusWithProgressIcon.kt
+++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/presentation/components/StatusWithProgressIcon.kt
@@ -5,9 +5,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Check
-import androidx.compose.material.icons.outlined.Close
-import androidx.compose.material.icons.outlined.QuestionMark
+import androidx.compose.material.icons.outlined.CheckCircle
+import androidx.compose.material.icons.outlined.Circle
+import androidx.compose.material.icons.outlined.RemoveCircle
+import androidx.compose.material.icons.outlined.Unpublished
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -26,7 +27,7 @@ import at.techbee.spectacled.theme.AppTheme
@Composable
fun StatusWithProgressIcon(
- status: Status,
+ status: Status?,
percent: Long? = null,
) {
@@ -38,17 +39,17 @@ fun StatusWithProgressIcon(
}
val icon = when(status) {
- Status.FINAL -> Icons.Outlined.Check
- Status.DRAFT -> Icons.Outlined.QuestionMark
- Status.CANCELLED -> Icons.Outlined.Close
- else -> null
+ Status.FINAL -> Icons.Outlined.CheckCircle
+ Status.DRAFT -> Icons.Outlined.Unpublished
+ Status.CANCELLED -> Icons.Outlined.RemoveCircle
+ else -> Icons.Outlined.Circle
}
val progress = when(status) {
Status.FINAL, Status.COMPLETED -> 1f
Status.DRAFT -> 0.33f
Status.IN_PROCESS -> (percent?:1L)/100f
- Status.NEEDS_ACTION, Status.CANCELLED -> 0f
+ null, Status.NEEDS_ACTION, Status.CANCELLED -> 0f
}
Box(contentAlignment = Alignment.Center) {
@@ -61,17 +62,12 @@ fun StatusWithProgressIcon(
fontWeight = FontWeight.Bold,
fontSize = 9.sp
)
- }
-
- icon?.let {
- Icon(
- imageVector = it,
- contentDescription = null,
- tint = ProgressIndicatorDefaults.circularColor,
- modifier = Modifier.size(14.dp)
- )
- }
-
+ } ?: Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = if(status == Status.CANCELLED) MaterialTheme.colorScheme.error else ProgressIndicatorDefaults.circularColor,
+ modifier = Modifier.size(14.dp)
+ )
CircularProgressIndicator(
progress = { progress },
@@ -85,6 +81,9 @@ fun StatusWithProgressIcon(
private fun StatusWithProgress_Icon_Preview() {
AppTheme(spectacledVariant = SpectacledVariant.JOURNALS) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+
+ StatusWithProgressIcon(status = null)
+
StatusWithProgressIcon(status = Status.FINAL)
StatusWithProgressIcon(status = Status.DRAFT)
StatusWithProgressIcon(status = Status.CANCELLED)
diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsAction.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsAction.kt
index 29b6dc5f..ee5ef312 100644
--- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsAction.kt
+++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsAction.kt
@@ -17,6 +17,7 @@ sealed interface DetailsAction {
data class OnUpdateDtStart(val icsDateTime: IcsDateTime?): DetailsAction
data class OnUpdateDue(val icsDateTime: IcsDateTime?): DetailsAction
data class OnUpdateProgress(val percent: Long): DetailsAction
+ data class OnUpdatePriority(val priority: Long?): DetailsAction
data class OnUpdateSubtaskProgress(val percent: Long, val subtaskIcalEntryId: Long): DetailsAction
data class OnAddSubtask(val summary: String): DetailsAction
data class OnUpdateUrl(val url: Url?): DetailsAction
diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreenRoot.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreenRoot.kt
index a51bc517..83ad9fee 100644
--- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreenRoot.kt
+++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsScreenRoot.kt
@@ -61,7 +61,6 @@ import at.techbee.spectacled.screens.Route.IcalEntryDetails
import at.techbee.spectacled.screens.core.Platforms
import at.techbee.spectacled.screens.core.domain.CalendarComponent
import at.techbee.spectacled.screens.core.domain.IcalEntry
-import at.techbee.spectacled.screens.core.domain.Status
import at.techbee.spectacled.screens.core.domain.SyncState
import at.techbee.spectacled.screens.core.getPlatform
import at.techbee.spectacled.screens.core.presentation.components.BottomSheetWithMenu
@@ -236,9 +235,11 @@ fun DetailsScreenRoot(
TaskStatusProgressPickerBottomSheet(
status = detailsState.icalEntry.status,
percentComplete = detailsState.icalEntry.percentComplete,
+ priority = detailsState.icalEntry.priority,
sheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
onStatusUpdated = { detailsViewModel.onAction(DetailsAction.OnUpdateStatus(it)) },
onProgressUpdated = { detailsViewModel.onAction(DetailsAction.OnUpdateProgress(it)) },
+ onPriorityUpdated = { detailsViewModel.onAction(DetailsAction.OnUpdatePriority(it)) },
onDismiss = { detailsViewModel.onAction(DetailsAction.OnShowSheetOrDialog(null)) }
)
DetailsSheetOrDialog.ADD_SUBTASKS ->
@@ -386,8 +387,7 @@ fun DetailsScreenRoot(
enabled = detailsState.allowEditing() && !detailsState.isLoading
) {
StatusWithProgressIcon(
- status = detailsState.icalEntry.status
- ?: if(detailsState.icalEntry.isTask()) Status.NEEDS_ACTION else Status.FINAL,
+ status = detailsState.icalEntry.status,
percent = detailsState.icalEntry.percentComplete
)
}
diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt
index 994d6f77..889c551d 100644
--- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt
+++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/DetailsViewModel.kt
@@ -353,6 +353,7 @@ class DetailsViewModel(
is DetailsAction.OnPin -> { onPinIcalEntry(action.pin) }
is DetailsAction.OnUpdateStatus -> { onUpdateStatus(action.status) }
is DetailsAction.OnUpdateProgress -> { onUpdateTaskProgress(action.percent) }
+ is DetailsAction.OnUpdatePriority -> { onUpdatePriority(action.priority) }
is DetailsAction.OnUpdateSubtaskProgress -> { onUpdateSubtaskProgress(action.percent, action.subtaskIcalEntryId) }
is DetailsAction.OnAddSubtask -> { insertSubtask(action.summary) }
is DetailsAction.OnNavigateToIcalEntryId -> { _state.update { it.copy(navigateToIcalEntryId = action.id) } }
@@ -482,6 +483,22 @@ class DetailsViewModel(
}
}
+ private fun onUpdatePriority(newPriority: Long?) {
+
+ if(!state.value.allowEditing())
+ return
+
+ _state.update {
+ it.copy(
+ icalEntry = it.icalEntry.copy(
+ priority = newPriority,
+ lastModified = IcsDateTime.now(),
+ syncState = it.icalEntry.syncState.afterLocalEdit()
+ )
+ )
+ }
+ }
+
private fun onUpdateColor(newColor: Color?) {
diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/JournalStatusPickerBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/JournalStatusPickerBottomSheet.kt
index d4178099..c1e731ba 100644
--- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/JournalStatusPickerBottomSheet.kt
+++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/JournalStatusPickerBottomSheet.kt
@@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp
import at.techbee.spectacled.SpectacledVariant
import at.techbee.spectacled.screens.core.domain.Status
import at.techbee.spectacled.screens.core.presentation.components.BottomSheetWithMenu
+import at.techbee.spectacled.screens.core.presentation.components.StatusWithProgressIcon
import at.techbee.spectacled.theme.AppTheme
import org.jetbrains.compose.resources.stringResource
import spectacled.shared.generated.resources.Res
@@ -45,10 +46,6 @@ fun JournalStatusPickerBottomSheet(
Text(stringResource(Res.string.done))
}
},
- menuActionLeft = { TextButton(onClick = {
- onStatusUpdated(null)
- onDismiss()
- }) { Text(stringResource(Res.string.status_no_status)) } }
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -58,6 +55,14 @@ fun JournalStatusPickerBottomSheet(
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
) {
+
+ FilterChip(
+ leadingIcon = { StatusWithProgressIcon(null, null) },
+ selected = status == null,
+ onClick = { onStatusUpdated(null) },
+ label = { Text(stringResource(Res.string.status_no_status)) }
+ )
+
val statusSet = setOf(Status.DRAFT, Status.FINAL, Status.CANCELLED)
statusSet.forEach { selectableStatus ->
@@ -90,4 +95,20 @@ private fun JournalStatusPickerBottomSheet_Preview() {
}
}
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview(showBackground = true)
+@Composable
+private fun JournalStatusPickerBottomSheet_NO_STATUS_Preview() {
+ AppTheme(spectacledVariant = SpectacledVariant.JOURNALS) {
+ Scaffold {
+ JournalStatusPickerBottomSheet(
+ status = null,
+ sheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
+ onStatusUpdated = {},
+ onDismiss = {}
+ )
+ }
+ }
+}
+
diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/TaskStatusProgressPickerBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/TaskStatusProgressPickerBottomSheet.kt
index 4e580e08..be82fd84 100644
--- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/TaskStatusProgressPickerBottomSheet.kt
+++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/details/presentation/components/TaskStatusProgressPickerBottomSheet.kt
@@ -1,13 +1,20 @@
package at.techbee.spectacled.screens.details.presentation.components
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
@@ -18,29 +25,41 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TriStateCheckbox
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.state.ToggleableState
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import at.techbee.spectacled.SpectacledVariant
+import at.techbee.spectacled.screens.core.domain.Priority
import at.techbee.spectacled.screens.core.domain.Status
import at.techbee.spectacled.screens.core.presentation.components.BottomSheetWithMenu
+import at.techbee.spectacled.screens.core.presentation.components.StatusWithProgressIcon
import at.techbee.spectacled.theme.AppTheme
import org.jetbrains.compose.resources.stringResource
import spectacled.shared.generated.resources.Res
import spectacled.shared.generated.resources.done
import spectacled.shared.generated.resources.percent_complete
+import spectacled.shared.generated.resources.priority
import spectacled.shared.generated.resources.status_no_status
+import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskStatusProgressPickerBottomSheet(
status: Status?,
percentComplete: Long?,
+ priority: Long?,
sheetState: SheetState,
onProgressUpdated: (Long) -> Unit,
onStatusUpdated: (Status?) -> Unit,
+ onPriorityUpdated: (Long?) -> Unit,
onDismiss: () -> Unit
) {
@@ -53,11 +72,7 @@ fun TaskStatusProgressPickerBottomSheet(
) {
Text(stringResource(Res.string.done))
}
- },
- menuActionLeft = { TextButton(onClick = {
- onStatusUpdated(null)
- onDismiss()
- }) { Text(stringResource(Res.string.status_no_status)) } }
+ }
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -93,6 +108,14 @@ fun TaskStatusProgressPickerBottomSheet(
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
) {
+
+ FilterChip(
+ leadingIcon = { StatusWithProgressIcon(null, null) },
+ selected = status == null,
+ onClick = { onStatusUpdated(null) },
+ label = { Text(stringResource(Res.string.status_no_status)) }
+ )
+
val statusSet = setOf(Status.NEEDS_ACTION, Status.IN_PROCESS, Status.COMPLETED, Status.CANCELLED)
statusSet.forEach { selectableStatus ->
@@ -104,6 +127,56 @@ fun TaskStatusProgressPickerBottomSheet(
)
}
}
+
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp))
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)
+ ) {
+
+ Text(stringResource(Res.string.priority))
+
+ val currentPriority = Priority.fromIcsValue(priority)
+
+ // Local continuous value for smooth dragging
+ var sliderValue by remember(priority) { mutableFloatStateOf(currentPriority.ordinal.toFloat()) }
+
+ Slider(
+ value = sliderValue,
+ valueRange = 0f..(Priority.entries.size - 1).toFloat(),
+ steps = Priority.entries.size - 2,
+ onValueChange = { sliderValue = it },
+ onValueChangeFinished = {
+ val newPriority = Priority.fromSliderStep(sliderValue.roundToInt())
+ if (newPriority != currentPriority)
+ onPriorityUpdated(newPriority.icsValue)
+ },
+ thumb = {
+ // Plain Box rather than a chip/Surface so the thumb stays non-interactive and
+ // every press reaches the Slider's own drag handling.
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.surfaceContainerLow, AssistChipDefaults.shape)
+ .border(1.dp, MaterialTheme.colorScheme.outlineVariant, AssistChipDefaults.shape)
+ ) {
+ Text(
+ text = stringResource(Priority.fromSliderStep(sliderValue.roundToInt()).stringRes),
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .padding(horizontal = 8.dp, vertical = 6.dp)
+ .width(72.dp)
+ )
+ }
+ },
+ modifier = Modifier.weight(1f)
+ )
+ }
}
}
}
@@ -119,9 +192,11 @@ private fun TaskStatusProgressPickerBottomSheet_Preview() {
TaskStatusProgressPickerBottomSheet(
status = null,
percentComplete = 0L,
+ priority = 5,
sheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
onStatusUpdated = {},
onProgressUpdated = {},
+ onPriorityUpdated = {},
onDismiss = {}
)
}
@@ -137,9 +212,11 @@ private fun TaskStatusProgressPickerBottomSheet_inprocess_Preview() {
TaskStatusProgressPickerBottomSheet(
status = Status.IN_PROCESS,
percentComplete = 33,
+ priority = null,
sheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
onStatusUpdated = {},
onProgressUpdated = {},
+ onPriorityUpdated = {},
onDismiss = {}
)
}
diff --git a/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/PriorityTest.kt b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/PriorityTest.kt
new file mode 100644
index 00000000..fcaee769
--- /dev/null
+++ b/shared/src/commonTest/kotlin/at/techbee/spectacled/screens/core/domain/PriorityTest.kt
@@ -0,0 +1,62 @@
+package at.techbee.spectacled.screens.core.domain
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class PriorityTest {
+
+ @Test
+ fun fromIcsValue_followsRfc5545Grouping() {
+ val expected = mapOf(
+ 1L to Priority.HIGH,
+ 2L to Priority.HIGH,
+ 3L to Priority.HIGH,
+ 4L to Priority.HIGH,
+ 5L to Priority.MEDIUM,
+ 6L to Priority.LOW,
+ 7L to Priority.LOW,
+ 8L to Priority.LOW,
+ 9L to Priority.LOW
+ )
+ expected.forEach { (icsValue, priority) ->
+ assertEquals(priority, Priority.fromIcsValue(icsValue), "fromIcsValue($icsValue)")
+ }
+ }
+
+ @Test
+ fun fromIcsValue_undefinedAndOutOfRange_isNotSpecified() {
+ listOf(null, 0L, -1L, 10L, Long.MAX_VALUE).forEach { icsValue ->
+ assertEquals(Priority.NOT_SPECIFIED, Priority.fromIcsValue(icsValue), "fromIcsValue($icsValue)")
+ }
+ }
+
+ @Test
+ fun icsValue_roundTripsToSameLevel() {
+ Priority.entries.forEach { priority ->
+ assertEquals(priority, Priority.fromIcsValue(priority.icsValue), "round trip for $priority")
+ }
+ }
+
+ @Test
+ fun icsValue_perLevel() {
+ assertEquals(null, Priority.NOT_SPECIFIED.icsValue)
+ assertEquals(9L, Priority.LOW.icsValue)
+ assertEquals(5L, Priority.MEDIUM.icsValue)
+ assertEquals(1L, Priority.HIGH.icsValue)
+ }
+
+ @Test
+ fun sliderSteps_orderedFromLowestToHighest() {
+ assertEquals(
+ listOf(Priority.NOT_SPECIFIED, Priority.LOW, Priority.MEDIUM, Priority.HIGH),
+ (0..3).map { Priority.fromSliderStep(it) }
+ )
+ }
+
+ @Test
+ fun fromSliderStep_outOfRange_isNotSpecified() {
+ listOf(-1, 4, Int.MAX_VALUE).forEach { step ->
+ assertEquals(Priority.NOT_SPECIFIED, Priority.fromSliderStep(step), "fromSliderStep($step)")
+ }
+ }
+}