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