diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 59dec480..0aa8fdf1 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -91,6 +91,8 @@ Take new photo Upload Yes + Add new meal type + Edit meal type kitshn @@ -111,6 +113,7 @@ API token by Category + Color Comment Contact Cookbook @@ -194,6 +197,7 @@ Supermarket Shopping list Tags + Time Waiting time Working time Title @@ -445,6 +449,25 @@ The version of your Tandoor server has not yet been checked. The functionality of the app may be impaired. Compatibility unknown + + Light Red + Magenta + Lavender + Blue + Teal + Red + Green + Olive + Gray + Black + White + Dark Gray + Light Gray + Cyan + Yellow + + + hours hour diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/Utils.kt b/composeApp/src/commonMain/kotlin/de/kitshn/Utils.kt index 9a3282d0..16429000 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/Utils.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/Utils.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType import co.touchlab.kermit.Logger @@ -37,6 +38,7 @@ import kotlinx.coroutines.delay import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.format @@ -312,6 +314,29 @@ fun Long.toLocalDate( .toLocalDateTime(timeZone).date } +enum class TimePrecision { NONE, SECONDS, MINUTES, HOURS, DAY, MONTH, YEAR} + +/** + * Provides a rounded date and time. + */ +fun getRoundedDateTime( + precision: TimePrecision = TimePrecision.NONE, + clock: Clock = Clock.System, + timeZone: TimeZone = TimeZone.currentSystemDefault() +): LocalDateTime { + val now = clock.now().toLocalDateTime(timeZone) + + return when (precision) { + TimePrecision.NONE -> now + TimePrecision.SECONDS -> LocalDateTime(now.year, now.monthNumber, now.dayOfMonth, now.hour, now.minute, now.second) + TimePrecision.MINUTES -> LocalDateTime(now.year, now.monthNumber, now.dayOfMonth, now.hour, now.minute) + TimePrecision.HOURS -> LocalDateTime(now.year, now.monthNumber, now.dayOfMonth, now.hour, 0) + TimePrecision.DAY -> LocalDateTime(now.year, now.monthNumber, now.dayOfMonth, 0, 0) + TimePrecision.MONTH -> LocalDateTime(now.year, now.monthNumber, 1, 0, 0) + TimePrecision.YEAR -> LocalDateTime(now.year, 1, 1, 0, 0) + } +} + @Composable expect fun LocalDate.format(pattern: String): String diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/model/TandoorMealPlan.kt b/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/model/TandoorMealPlan.kt index a6249364..6df097b8 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/model/TandoorMealPlan.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/model/TandoorMealPlan.kt @@ -1,6 +1,7 @@ package de.kitshn.api.tandoor.model import androidx.compose.ui.graphics.Color +import com.materialkolor.ktx.toHex import de.kitshn.api.tandoor.TandoorClient import de.kitshn.api.tandoor.delete import de.kitshn.api.tandoor.model.recipe.TandoorRecipeOverview @@ -10,6 +11,7 @@ import de.kitshn.parseTandoorDate import de.kitshn.toColorInt import de.kitshn.toStartOfDayString import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @@ -33,6 +35,42 @@ data class TandoorMealType( } else { Color(colorStr?.toColorInt() ?: -1) } + + suspend fun delete(client: TandoorClient): String { + val response = client.delete("/meal-type/${id}/") + + if (response.status.value in 200..299) { + client.container.mealType.remove(id) + } + + return response.status.value.toString() + } + + suspend fun partialUpdate( + client: TandoorClient, + name: String? = null, + order: Int? = null, + time: LocalTime? = null, + color: Color? = null, + ): TandoorMealType { + val data = buildJsonObject { + if(name != null) put("name", JsonPrimitive(name)) + if(order != null) put("order", json.encodeToJsonElement(order)) + if(time != null) put("time", JsonPrimitive(time.toString())) + if(color != null) put("color", JsonPrimitive(color.toHex())) + } + + val updated = parse(client.patchObject("/meal-type/${id}/", data).toString()) + client.container.mealType[updated.id] = updated + return updated + } + + companion object { + fun parse(data: String): TandoorMealType { + val obj = json.decodeFromString(data) + return obj + } + } } @Serializable @@ -59,6 +97,7 @@ class TandoorMealPlan( val obj = json.decodeFromString(data) obj.client = client obj.recipe?.let { client.container.recipeOverview[it.id] = it } + obj.meal_type.let { client.container.mealType[it.id] = it } return obj } } diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/route/TandoorMealTypeRoute.kt b/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/route/TandoorMealTypeRoute.kt index 18d2aef7..3ebd840e 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/route/TandoorMealTypeRoute.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/route/TandoorMealTypeRoute.kt @@ -1,10 +1,18 @@ package de.kitshn.api.tandoor.route +import androidx.compose.ui.graphics.Color +import com.materialkolor.ktx.toHex import de.kitshn.api.tandoor.TandoorClient import de.kitshn.api.tandoor.getObject import de.kitshn.api.tandoor.model.TandoorMealType import de.kitshn.api.tandoor.model.TandoorPagedResponse +import de.kitshn.api.tandoor.patchObject +import de.kitshn.api.tandoor.postObject import de.kitshn.json +import kotlinx.datetime.LocalTime +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement class TandoorMealTypeRoute(client: TandoorClient) : TandoorBaseRoute(client) { @@ -20,4 +28,25 @@ class TandoorMealTypeRoute(client: TandoorClient) : TandoorBaseRoute(client) { return response.results } + suspend fun create( + name: String, + order: Int?, + time: LocalTime? = null, + color: Color? = null, + ): TandoorMealType { + val data = buildJsonObject { + put("name", JsonPrimitive(name)) + if(order != null) put("order", json.encodeToJsonElement(order)) + if(time != null) put("time", JsonPrimitive(time.toString())) + if(color != null) put("color", JsonPrimitive(color.toHex())) + } + + val mealType = TandoorMealType.parse( + client.postObject("/meal-type/", data).toString() + ) + + client.container.mealType[mealType.id] = mealType + + return mealType + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/model/form/item/field/KitshnFormColorFieldItem.kt b/composeApp/src/commonMain/kotlin/de/kitshn/model/form/item/field/KitshnFormColorFieldItem.kt new file mode 100644 index 00000000..3d8d1b9c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/de/kitshn/model/form/item/field/KitshnFormColorFieldItem.kt @@ -0,0 +1,104 @@ +package de.kitshn.model.form.item.field + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import de.kitshn.ui.component.input.ColorPickerField +import kitshn.composeapp.generated.resources.Res +import kitshn.composeapp.generated.resources.form_error_field_empty +import org.jetbrains.compose.resources.getString + +class KitshnFormColorFieldItem( + val value: () -> Color?, + val onValueChange: (value: Color?) -> Unit, + + label: @Composable () -> Unit, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + + optional: Boolean = false, + + val check: (value: Color?) -> String? +) : KitshnFormBaseFieldItem( + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + optional = optional +) { + + @Composable + override fun Render( + modifier: Modifier + ) { + var error by rememberSaveable { mutableStateOf(null) } + val value = value() + + ColorPickerField( + modifier = modifier, + + value = value, + label = { + Row { + label() + if(!optional) Text("*") + } + }, + + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + + isError = error != null || generalError != null, + supportingText = if(error != null || generalError != null) { + { + Text( + text = error ?: generalError ?: "", + color = MaterialTheme.colorScheme.error + ) + } + } else null, + + onValueChange = { + generalError = null + onValueChange(it) + + error = if(it == null) + null + else + check(it) + } + ) + } + + override suspend fun submit(): Boolean { + val value = value() + val checkResult = check(value) + + if(!optional && value == null) { + generalError = getString(Res.string.form_error_field_empty) + return false + } else if(checkResult != null) { + generalError = checkResult + return false + } else { + generalError = null + return true + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/model/form/item/field/KitshnFormMealTypeSearchFieldItem.kt b/composeApp/src/commonMain/kotlin/de/kitshn/model/form/item/field/KitshnFormMealTypeSearchFieldItem.kt index 424fbd81..87c395bd 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/model/form/item/field/KitshnFormMealTypeSearchFieldItem.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/model/form/item/field/KitshnFormMealTypeSearchFieldItem.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import de.kitshn.api.tandoor.TandoorClient -import de.kitshn.ui.component.input.MealTypeSearchField +import de.kitshn.ui.component.input.MealTypePickerField import kitshn.composeapp.generated.resources.Res import kitshn.composeapp.generated.resources.form_error_field_empty import org.jetbrains.compose.resources.getString @@ -55,11 +55,10 @@ class KitshnFormMealTypeSearchFieldItem( var error by rememberSaveable { mutableStateOf(null) } val value = value() - MealTypeSearchField( + MealTypePickerField( modifier = modifier.fillMaxWidth(), client = client, value = value, - useDefaultMealTypeIfNull = true, label = { Row { label() @@ -89,15 +88,14 @@ class KitshnFormMealTypeSearchFieldItem( keyboardOptions = KeyboardOptions( imeAction = ImeAction.Next ), - onValueChange = { generalError = null onValueChange(it) - error = if(it == null) null else check(it) + } ) } @@ -118,4 +116,4 @@ class KitshnFormMealTypeSearchFieldItem( } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/model/form/item/field/KitshnFormTimeFieldItem.kt b/composeApp/src/commonMain/kotlin/de/kitshn/model/form/item/field/KitshnFormTimeFieldItem.kt new file mode 100644 index 00000000..8d2860d5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/de/kitshn/model/form/item/field/KitshnFormTimeFieldItem.kt @@ -0,0 +1,104 @@ +package de.kitshn.model.form.item.field + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import de.kitshn.ui.component.input.TimeField +import kitshn.composeapp.generated.resources.Res +import kitshn.composeapp.generated.resources.form_error_field_empty +import kotlinx.datetime.LocalTime +import org.jetbrains.compose.resources.getString + +class KitshnFormTimeFieldItem( + val value: () -> LocalTime?, + val onValueChange: (value: LocalTime?) -> Unit, + + label: @Composable () -> Unit, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + + optional: Boolean = false, + + val check: (value: LocalTime?) -> String? +) : KitshnFormBaseFieldItem( + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + optional = optional +) { + + @Composable + override fun Render( + modifier: Modifier + ) { + var error by rememberSaveable { mutableStateOf(null) } + val value = value() + + TimeField( + modifier = modifier, + + value = value, + label = { + Row { + label() + if(!optional) Text("*") + } + }, + + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + + isError = error != null || generalError != null, + supportingText = if(error != null || generalError != null) { + { + Text( + text = error ?: generalError ?: "", + color = MaterialTheme.colorScheme.error + ) + } + } else null, + + onValueChange = { + generalError = null + onValueChange(it) + + error = if(it == null) + null + else + check(it) + } + ) + } + + override suspend fun submit(): Boolean { + val value = value() + val checkResult = check(value) + + if(!optional && value == null) { + generalError = getString(Res.string.form_error_field_empty) + return false + } else if(checkResult != null) { + generalError = checkResult + return false + } else { + generalError = null + return true + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/ColorPickerField.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/ColorPickerField.kt new file mode 100644 index 00000000..60cb56eb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/ColorPickerField.kt @@ -0,0 +1,323 @@ +package de.kitshn.ui.component.input + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import com.materialkolor.ktx.toHex +import de.kitshn.ui.theme.custom.BLUE_COLOR_SCHEME_SEED +import de.kitshn.ui.theme.custom.GREEN_COLOR_SCHEME_SEED +import de.kitshn.ui.theme.custom.LIGHT_RED_COLOR_SCHEME_SEED +import de.kitshn.ui.theme.custom.LILA_COLOR_SCHEME_SEED +import de.kitshn.ui.theme.custom.MAGENTA_COLOR_SCHEME_SEED +import de.kitshn.ui.theme.custom.OLIVE_COLOR_SCHEME_SEED +import de.kitshn.ui.theme.custom.RED_COLOR_SCHEME_SEED +import de.kitshn.ui.theme.custom.TEAL_COLOR_SCHEME_SEED +import kitshn.composeapp.generated.resources.Res +import kitshn.composeapp.generated.resources.color_black +import kitshn.composeapp.generated.resources.color_blue +import kitshn.composeapp.generated.resources.color_cyan +import kitshn.composeapp.generated.resources.color_dark_gray +import kitshn.composeapp.generated.resources.color_gray +import kitshn.composeapp.generated.resources.color_green +import kitshn.composeapp.generated.resources.color_light_gray +import kitshn.composeapp.generated.resources.color_light_red +import kitshn.composeapp.generated.resources.color_lila +import kitshn.composeapp.generated.resources.color_magenta +import kitshn.composeapp.generated.resources.color_olive +import kitshn.composeapp.generated.resources.color_red +import kitshn.composeapp.generated.resources.color_teal +import kitshn.composeapp.generated.resources.color_white +import kitshn.composeapp.generated.resources.color_yellow +import kitshn.composeapp.generated.resources.common_color +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +val PREDEFINED_COLORS = listOf( + RED_COLOR_SCHEME_SEED, + LIGHT_RED_COLOR_SCHEME_SEED, + MAGENTA_COLOR_SCHEME_SEED, + LILA_COLOR_SCHEME_SEED, + BLUE_COLOR_SCHEME_SEED, + TEAL_COLOR_SCHEME_SEED, + GREEN_COLOR_SCHEME_SEED, + OLIVE_COLOR_SCHEME_SEED, + Color.Gray, + Color.Black, + Color.White, + Color.DarkGray, + Color.LightGray, + Color.Cyan, + Color.Magenta, + Color.Yellow +) + +fun Color.toNameResource(): StringResource? { + return when (this) { + RED_COLOR_SCHEME_SEED -> Res.string.color_red + LIGHT_RED_COLOR_SCHEME_SEED -> Res.string.color_light_red + MAGENTA_COLOR_SCHEME_SEED -> Res.string.color_magenta + LILA_COLOR_SCHEME_SEED -> Res.string.color_lila + BLUE_COLOR_SCHEME_SEED -> Res.string.color_blue + TEAL_COLOR_SCHEME_SEED -> Res.string.color_teal + GREEN_COLOR_SCHEME_SEED -> Res.string.color_green + OLIVE_COLOR_SCHEME_SEED -> Res.string.color_olive + Color.Gray -> Res.string.color_gray + Color.Black -> Res.string.color_black + Color.White -> Res.string.color_white + Color.DarkGray -> Res.string.color_dark_gray + Color.LightGray -> Res.string.color_light_gray + Color.Cyan -> Res.string.color_cyan + Color.Magenta -> Res.string.color_magenta + Color.Yellow -> Res.string.color_yellow + else -> null + } +} + +@Composable +fun Color.toName(includeHex: Boolean = false): String { + val color = this + val resource = color.toNameResource() + + return buildString { + if (resource != null) { + append(stringResource(resource)) + if (includeHex) { + append(" (") + append(color.toHex()) + append(")") + } + } else { + append(color.toHex()) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BaseColorPickerField( + value: Color?, + onValueChange: (Color?) -> Unit, + content: @Composable ( + thumbnail: @Composable (() -> Unit)?, + text: String, + onClick: () -> Unit + ) -> Unit +) { + var showColorPickerDialog by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState() + + //TODO: allow custom hex input and more + content( + if (value != null) { + { + Box( + Modifier + .size(24.dp) + .clip(CircleShape) + .background(value) + ) + } + } else null, + value?.toName(includeHex = true) ?: "", + { + showColorPickerDialog = true + } + ) + + if (showColorPickerDialog) { + ModalBottomSheet( + onDismissRequest = { showColorPickerDialog = false }, + sheetState = sheetState + ) { + Text( + text = stringResource(Res.string.common_color), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(16.dp) + ) + LazyVerticalGrid( + columns = GridCells.Adaptive(46.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp) + ) { + items(PREDEFINED_COLORS) { color -> + val isSelected = color == value + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .aspectRatio(1f) + .clip(CircleShape) + .background(color) + .then( + if (isSelected) { + Modifier.border( + BorderStroke(4.dp, MaterialTheme.colorScheme.primary), + CircleShape + ) + } else Modifier + ) + .clickable { + onValueChange(color) + showColorPickerDialog = false + } + ) { + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = if (color.luminance() > 0.5f) Color.Black else Color.White, + modifier = Modifier.size(32.dp) + ) + } + } + } + } + } + } +} + +@Composable +fun OutlinedColorPickerField( + value: Color?, + onValueChange: (Color?) -> Unit, + modifier: Modifier = Modifier, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + shape: Shape = OutlinedTextFieldDefaults.shape, + colors: TextFieldColors = OutlinedTextFieldDefaults.colors() +) = BaseColorPickerField( + value = value, + onValueChange = onValueChange +) { t, text, onClick -> + OutlinedTextField( + value = text, + modifier = modifier + .fillMaxWidth(), + enabled = true, + readOnly = true, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = t ?: leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + isError = isError, + shape = shape, + colors = colors, + interactionSource = remember { MutableInteractionSource() } + .also { interactionSource -> + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { + if(it !is FocusInteraction.Focus && it !is PressInteraction.Release) return@collect + onClick() + } + } + }, + onValueChange = { } + ) +} + +@Composable +fun ColorPickerField( + value: Color?, + onValueChange: (Color?) -> Unit, + modifier: Modifier = Modifier, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors() +) = BaseColorPickerField( + value = value, + onValueChange = onValueChange +) { t, text, onClick -> + TextField( + value = text, + modifier = modifier + .fillMaxWidth(), + enabled = true, + readOnly = true, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = t ?: leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + isError = isError, + shape = shape, + colors = colors, + interactionSource = remember { MutableInteractionSource() } + .also { interactionSource -> + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { + if(it !is FocusInteraction.Focus && it !is PressInteraction.Release) return@collect + onClick() + } + } + }, + onValueChange = { } + ) +} diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/MealTypePickerField.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/MealTypePickerField.kt new file mode 100644 index 00000000..1a5bf48c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/MealTypePickerField.kt @@ -0,0 +1,333 @@ +package de.kitshn.ui.component.input + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.UnfoldMore +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import co.touchlab.kermit.Logger +import de.kitshn.api.tandoor.TandoorClient +import de.kitshn.api.tandoor.TandoorRequestsError +import de.kitshn.api.tandoor.model.TandoorMealType +import de.kitshn.ui.dialog.mealtype.MealTypeCreationAndEditDialog +import de.kitshn.ui.dialog.mealtype.rememberMealTypeCreationAndEditDialogState +import kitshn.composeapp.generated.resources.Res +import kitshn.composeapp.generated.resources.action_add_meal_type +import kitshn.composeapp.generated.resources.common_unknown_meal_type +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BaseMealTypePickerField( + client: TandoorClient, + value: Int? = null, + onValueChange: (Int?) -> Unit, + content: @Composable ( + thumbnail: @Composable (() -> Unit)?, + value: String, + onClick: () -> Unit + ) -> Unit +) { + val focus = LocalFocusManager.current + + var isExpanded by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + + val mealTypeList = remember { mutableStateListOf() } + LaunchedEffect(Unit) { + try { + client.mealType.fetch().let { mealTypes -> + mealTypeList.clear() + mealTypeList.addAll(mealTypes.sortedWith(compareBy({ it.order }, { it.time }))) + } + } catch(e: TandoorRequestsError) { + Logger.e("MealTypePickerField.kt", e) + } + } + val selectedMealType = mealTypeList.firstOrNull { it.id == value } + + val text = if (value == null) "" else (selectedMealType?.name ?: stringResource(Res.string.common_unknown_meal_type)) + content( + if(selectedMealType != null) { + { + Box( + Modifier + .size(20.dp) + .clip(RoundedCornerShape(4.dp)) + .background(selectedMealType.color) + ) + } + } else null, + text + ) { + focus.clearFocus() + isExpanded = true + } + + val createAndEditDialogState = rememberMealTypeCreationAndEditDialogState( + "MealTypePickerField/mealTypeCreationAndEditState" + ) + + MealTypeCreationAndEditDialog( + client = client, + state = createAndEditDialogState, + onSaved = { savedMealType -> + val index = mealTypeList.indexOfFirst { it.id == savedMealType.id } + if (index >= 0) { + mealTypeList[index] = savedMealType + } else { + mealTypeList.add(savedMealType) + + val sorted = mealTypeList.sortedWith(compareBy({ it.order }, { it.time })) + mealTypeList.clear() + mealTypeList.addAll(sorted) + } + + onValueChange(savedMealType.id) + isExpanded = false + }, + onDeleted = { deletedMealType -> + mealTypeList.removeAll { it.id == deletedMealType.id } + if (selectedMealType?.id == deletedMealType.id) { + onValueChange(null) + } + } + ) + + if(!isExpanded) return + + ModalBottomSheet( + onDismissRequest = { isExpanded = false }, + sheetState = sheetState + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + items(mealTypeList) { mealType -> + ListItem( + headlineContent = { Text(mealType.name) }, + leadingContent = { + Box( + Modifier + .size(20.dp) + .clip(RoundedCornerShape(4.dp)) + .background(mealType.color) + ) + }, + trailingContent = { + if(mealType.id == selectedMealType?.id) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.combinedClickable( + onClick = { + onValueChange(mealType.id) + isExpanded = false + }, + onLongClick = { + createAndEditDialogState.edit(mealType) + } + ) + ) + } + + if (mealTypeList.isNotEmpty()){ + item { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + } + } + + item { + ListItem( + headlineContent = { Text(stringResource(Res.string.action_add_meal_type)) }, + leadingContent = { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource(Res.string.action_add_meal_type) + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable { + createAndEditDialogState.create() + } + ) + } + } + } +} + +@Composable +fun OutlinedMealTypePickerField( + client: TandoorClient, + value: Int? = null, + onValueChange: (Int?) -> Unit, + modifier: Modifier = Modifier, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + isError: Boolean = false, + shape: Shape = OutlinedTextFieldDefaults.shape, + colors: TextFieldColors = OutlinedTextFieldDefaults.colors() +) = BaseMealTypePickerField( + client = client, + value = value, + onValueChange = onValueChange +) { t, v, oc -> + OutlinedTextField( + value = v, + modifier = modifier + .fillMaxWidth() + .onFocusChanged { + if(it.isFocused) oc() + } + .pointerInput(Unit) { + detectTapGestures(onTap = { oc() }) + }, + enabled = true, + readOnly = true, + singleLine = true, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = t ?: leadingIcon, + trailingIcon = trailingIcon ?: { + Icon( + imageVector = Icons.Default.UnfoldMore, + contentDescription = null + ) + }, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + isError = isError, + shape = shape, + colors = colors, + onValueChange = { } + ) +} + +@Composable +fun MealTypePickerField( + client: TandoorClient, + value: Int? = null, + onValueChange: (Int?) -> Unit, + modifier: Modifier = Modifier, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + isError: Boolean = false, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors() +) = BaseMealTypePickerField( + client = client, + value = value, + onValueChange = onValueChange +) { t, v, oc -> + TextField( + value = v, + modifier = modifier + .fillMaxWidth() + .onFocusChanged { + if(it.isFocused) oc() + } + .pointerInput(Unit) { + detectTapGestures(onTap = { oc() }) + }, + enabled = true, + readOnly = true, + singleLine = true, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = t ?: leadingIcon, + trailingIcon = trailingIcon ?: { + Icon( + imageVector = Icons.Default.UnfoldMore, + contentDescription = null + ) + }, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + isError = isError, + shape = shape, + colors = colors, + onValueChange = { } + ) +} diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/MealTypeSearchField.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/MealTypeSearchField.kt deleted file mode 100644 index 0079c529..00000000 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/MealTypeSearchField.kt +++ /dev/null @@ -1,232 +0,0 @@ -package de.kitshn.ui.component.input - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuBoxScope -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MenuAnchorType -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldColors -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import co.touchlab.kermit.Logger -import de.kitshn.api.tandoor.TandoorClient -import de.kitshn.api.tandoor.TandoorRequestsError -import de.kitshn.api.tandoor.model.TandoorMealType -import kitshn.composeapp.generated.resources.Res -import kitshn.composeapp.generated.resources.common_unknown_meal_type -import org.jetbrains.compose.resources.getString - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun BaseMealTypeSearchField( - client: TandoorClient, - value: Int?, - onValueChange: (Int?) -> Unit, - useDefaultMealTypeIfNull: Boolean, - content: @Composable ExposedDropdownMenuBoxScope.( - thumbnail: @Composable (() -> Unit)?, - value: String, - onValueChange: (value: String) -> Unit - ) -> Unit -) { - val focus = LocalFocusManager.current - - var selectedMealType by remember { mutableStateOf(null) } - LaunchedEffect(selectedMealType) { onValueChange(selectedMealType?.id) } - - var isExpanded by remember { mutableStateOf(false) } - - var searchText by rememberSaveable { mutableStateOf("") } - LaunchedEffect(value) { - if(value == null) return@LaunchedEffect - if(selectedMealType?.id != value) selectedMealType = client.container.mealType[value] - - searchText = selectedMealType?.name ?: getString(Res.string.common_unknown_meal_type) - } - - val mealTypeList = remember { mutableStateListOf() } - LaunchedEffect(Unit) { - try { - client.mealType.fetch().let { - mealTypeList.clear() - mealTypeList.addAll(it) - } - } catch(e: TandoorRequestsError) { - Logger.e("MealTypeSearchField.kt", e) - } - } - - ExposedDropdownMenuBox( - expanded = isExpanded, - onExpandedChange = { - isExpanded = it - focus.clearFocus() - } - ) { - content( - if(selectedMealType != null) { - { - Box( - Modifier - .size(36.dp) - .clip(RoundedCornerShape(8.dp)) - .background(selectedMealType!!.color) - ) - } - } else null, - searchText - ) { - searchText = it - selectedMealType = null - } - - ExposedDropdownMenu( - expanded = isExpanded, - onDismissRequest = { - isExpanded = false - focus.clearFocus() - } - ) { - mealTypeList.forEach { - DropdownMenuItem( - text = { Text(it.name) }, - onClick = { - selectedMealType = it - isExpanded = false - - focus.clearFocus() - } - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun OutlinedMealTypeSearchField( - client: TandoorClient, - value: Int?, - onValueChange: (Int?) -> Unit, - useDefaultMealTypeIfNull: Boolean = false, - modifier: Modifier = Modifier, - textStyle: TextStyle = LocalTextStyle.current, - label: @Composable (() -> Unit)? = null, - placeholder: @Composable (() -> Unit)? = null, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - prefix: @Composable (() -> Unit)? = null, - suffix: @Composable (() -> Unit)? = null, - supportingText: @Composable (() -> Unit)? = null, - visualTransformation: VisualTransformation = VisualTransformation.None, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions.Default, - isError: Boolean = false, - shape: Shape = OutlinedTextFieldDefaults.shape, - colors: TextFieldColors = OutlinedTextFieldDefaults.colors() -) = BaseMealTypeSearchField( - client = client, - value = value, - onValueChange = onValueChange, - useDefaultMealTypeIfNull = useDefaultMealTypeIfNull -) { t, v, vc -> - OutlinedTextField( - value = v, - modifier = modifier.menuAnchor(MenuAnchorType.PrimaryEditable, true), - enabled = true, - readOnly = false, - singleLine = true, - textStyle = textStyle, - label = label, - placeholder = placeholder, - leadingIcon = t ?: leadingIcon, - trailingIcon = trailingIcon, - prefix = prefix, - suffix = suffix, - supportingText = supportingText, - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - isError = isError, - shape = shape, - colors = colors, - onValueChange = vc - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MealTypeSearchField( - client: TandoorClient, - value: Int?, - onValueChange: (Int?) -> Unit, - useDefaultMealTypeIfNull: Boolean = false, - modifier: Modifier = Modifier, - textStyle: TextStyle = LocalTextStyle.current, - label: @Composable (() -> Unit)? = null, - placeholder: @Composable (() -> Unit)? = null, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - prefix: @Composable (() -> Unit)? = null, - suffix: @Composable (() -> Unit)? = null, - supportingText: @Composable (() -> Unit)? = null, - visualTransformation: VisualTransformation = VisualTransformation.None, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions.Default, - isError: Boolean = false, - shape: Shape = TextFieldDefaults.shape, - colors: TextFieldColors = TextFieldDefaults.colors() -) = BaseMealTypeSearchField( - client = client, - value = value, - onValueChange = onValueChange, - useDefaultMealTypeIfNull = useDefaultMealTypeIfNull -) { t, v, vc -> - TextField( - value = v, - modifier = modifier.menuAnchor(MenuAnchorType.PrimaryEditable, true), - enabled = true, - readOnly = false, - singleLine = true, - textStyle = textStyle, - label = label, - placeholder = placeholder, - leadingIcon = t ?: leadingIcon, - trailingIcon = trailingIcon, - prefix = prefix, - suffix = suffix, - supportingText = supportingText, - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - isError = isError, - shape = shape, - colors = colors, - onValueChange = vc - ) -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/TimeField.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/TimeField.kt new file mode 100644 index 00000000..f311a1f8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/TimeField.kt @@ -0,0 +1,204 @@ +package de.kitshn.ui.component.input + +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TimeInput +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDialog +import androidx.compose.material3.TimePickerDialogDefaults +import androidx.compose.material3.TimePickerDisplayMode +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import kitshn.composeapp.generated.resources.Res +import kitshn.composeapp.generated.resources.action_abort +import kitshn.composeapp.generated.resources.common_okay +import kitshn.composeapp.generated.resources.common_time +import org.jetbrains.compose.resources.stringResource +import kotlinx.datetime.LocalTime + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BaseTimeField( + value: LocalTime?, + onValueChange: (LocalTime?) -> Unit, + content: @Composable ( + value: String, + onClick: () -> Unit + ) -> Unit +) { + var showTimePickerDialog by remember { mutableStateOf(false) } + + val timePickerState = rememberTimePickerState( + initialHour = value?.hour ?: 0, + initialMinute = value?.minute ?: 0, + is24Hour = true, + ) + + content( + value?.toString()?.substring(0, 5) ?: "" + ) { + showTimePickerDialog = true + } + + if (showTimePickerDialog) { + var displayMode by remember { mutableStateOf(TimePickerDisplayMode.Picker) } + + TimePickerDialog( + onDismissRequest = { showTimePickerDialog = false }, + confirmButton = { + TextButton( + onClick = { + onValueChange(LocalTime(timePickerState.hour, timePickerState.minute)) + showTimePickerDialog = false + } + ) { + Text(stringResource(Res.string.common_okay)) + } + }, + dismissButton = { + TextButton( + onClick = { showTimePickerDialog = false } + ) { + Text(stringResource(Res.string.action_abort)) + } + }, + title = { + Text(stringResource(Res.string.common_time)) + }, + modeToggleButton = { + TimePickerDialogDefaults.DisplayModeToggle( + onDisplayModeChange = { + displayMode = if (displayMode == TimePickerDisplayMode.Picker) { + TimePickerDisplayMode.Input + } else { + TimePickerDisplayMode.Picker + } + }, + displayMode = displayMode + ) + } + ) { + if (displayMode == TimePickerDisplayMode.Input) { + TimeInput(state = timePickerState) + } else { + TimePicker(state = timePickerState) + } + } + } +} + +@Composable +fun OutlinedTimeField( + value: LocalTime?, + onValueChange: (LocalTime?) -> Unit, + modifier: Modifier = Modifier, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + shape: Shape = OutlinedTextFieldDefaults.shape, + colors: TextFieldColors = OutlinedTextFieldDefaults.colors() +) = BaseTimeField( + value = value, + onValueChange = onValueChange +) { v, onClick -> + OutlinedTextField( + value = v, + modifier = modifier, + enabled = true, + readOnly = true, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + isError = isError, + shape = shape, + colors = colors, + interactionSource = remember { MutableInteractionSource() } + .also { interactionSource -> + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { + if(it !is FocusInteraction.Focus && it !is PressInteraction.Release) return@collect + onClick() + } + } + }, + onValueChange = { } + ) +} + +@Composable +fun TimeField( + value: LocalTime?, + onValueChange: (LocalTime?) -> Unit, + modifier: Modifier = Modifier, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors() +) = BaseTimeField( + value = value, + onValueChange = onValueChange +) { v, onClick -> + TextField( + value = v, + modifier = modifier, + enabled = true, + readOnly = true, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + isError = isError, + shape = shape, + colors = colors, + interactionSource = remember { MutableInteractionSource() } + .also { interactionSource -> + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { + if(it !is FocusInteraction.Focus && it !is PressInteraction.Release) return@collect + onClick() + } + } + }, + onValueChange = { } + ) +} diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/mealplan/MealPlanCreationAndEditDialog.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/mealplan/MealPlanCreationAndEditDialog.kt index 27f726fa..b1ab8b79 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/mealplan/MealPlanCreationAndEditDialog.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/mealplan/MealPlanCreationAndEditDialog.kt @@ -176,7 +176,7 @@ fun MealPlanCreationAndEditDialog( var recipeOverview by remember { mutableStateOf(null) } LaunchedEffect(recipeId) { recipeOverview = client.container.recipeOverview[recipeId] } - var mealTypeId by rememberSaveable { mutableStateOf(defaultValues.mealTypeId) } + var mealTypeId by remember { mutableStateOf(defaultValues.mealTypeId) } var startDate by remember { mutableStateOf(defaultValues.startDate) } var endDate by remember { mutableStateOf(defaultValues.endDate) } diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/mealtype/MealTypeCreationAndEditDialog.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/mealtype/MealTypeCreationAndEditDialog.kt new file mode 100644 index 00000000..8bb56bdb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/mealtype/MealTypeCreationAndEditDialog.kt @@ -0,0 +1,306 @@ +package de.kitshn.ui.dialog.mealtype + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Label +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.Timer +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.graphics.toArgb +import de.kitshn.TimePrecision +import de.kitshn.api.tandoor.TandoorClient +import de.kitshn.api.tandoor.model.TandoorMealType +import de.kitshn.api.tandoor.rememberTandoorRequestState +import de.kitshn.getRoundedDateTime +import de.kitshn.handleTandoorRequestState +import de.kitshn.model.form.KitshnForm +import de.kitshn.model.form.KitshnFormSection +import de.kitshn.model.form.item.field.KitshnFormColorFieldItem +import de.kitshn.model.form.item.field.KitshnFormTextFieldItem +import de.kitshn.model.form.item.field.KitshnFormTimeFieldItem +import de.kitshn.toColorInt +import de.kitshn.ui.TandoorRequestErrorHandler +import de.kitshn.ui.dialog.AdaptiveFullscreenDialog +import de.kitshn.ui.dialog.common.CommonDeletionDialog +import de.kitshn.ui.dialog.common.rememberCommonDeletionDialogState +import de.kitshn.ui.state.ColorStateSaver +import de.kitshn.ui.state.LocalTimeStateSaver +import de.kitshn.ui.state.foreverRememberNotSavable +import kitshn.composeapp.generated.resources.Res +import kitshn.composeapp.generated.resources.action_add_meal_type +import kitshn.composeapp.generated.resources.action_create +import kitshn.composeapp.generated.resources.action_delete +import kitshn.composeapp.generated.resources.action_edit_meal_type +import kitshn.composeapp.generated.resources.action_save +import kitshn.composeapp.generated.resources.common_color +import kitshn.composeapp.generated.resources.common_name +import kitshn.composeapp.generated.resources.common_time +import kitshn.composeapp.generated.resources.form_error_field_empty +import kitshn.composeapp.generated.resources.form_error_name_max_128 +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalTime +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource +import kotlin.time.ExperimentalTime + +data class MealTypeCreationAndEditValues @OptIn(ExperimentalTime::class) constructor( + val name: String = "", + val order: Int = 0, + val time: LocalTime = getRoundedDateTime(TimePrecision.HOURS).time, + val color: Color = Color.Gray, + val isDefault: Boolean = false, +) + +@OptIn(ExperimentalTime::class) +fun TandoorMealType.toValues(): MealTypeCreationAndEditValues { + return MealTypeCreationAndEditValues( + name = name, + order = order, + time = runCatching { time?.let { LocalTime.parse(it) } }.getOrNull() + ?: getRoundedDateTime(TimePrecision.HOURS).time, + color = Color(colorStr?.toColorInt() ?: Color.Gray.toArgb()), + isDefault = false, + ) +} + +class MealTypeCreationAndEditDialogState( + val shown: MutableState = mutableStateOf(false) +) { + @OptIn(ExperimentalTime::class) + var defaultValues = MealTypeCreationAndEditValues() + var mealType by mutableStateOf(null) + var isEdit = false + + fun create(values: MealTypeCreationAndEditValues = MealTypeCreationAndEditValues()) { + this.defaultValues = values + this.isEdit = false + this.shown.value = true + } + + fun edit(mealType: TandoorMealType) { + this.mealType = mealType + this.defaultValues = mealType.toValues() + this.isEdit = true + this.shown.value = true + } + + fun dismiss() { + this.shown.value = false + } +} + +@Composable +fun rememberMealTypeCreationAndEditDialogState( + key: String +): MealTypeCreationAndEditDialogState { + val value by foreverRememberNotSavable( + key = key, + initialValue = MealTypeCreationAndEditDialogState() + ) + + return value +} + +@OptIn(ExperimentalTime::class) +@Composable +fun MealTypeCreationAndEditDialog( + client: TandoorClient, + state: MealTypeCreationAndEditDialogState, + onSaved: (TandoorMealType) -> Unit, + onDeleted: (TandoorMealType) -> Unit +) { + + if (!state.shown.value) return + + val coroutineScope = rememberCoroutineScope() + val hapticFeedback = LocalHapticFeedback.current + + val deleteDialogState = rememberCommonDeletionDialogState() + + var name by rememberSaveable(state.defaultValues) { + mutableStateOf(state.defaultValues.name) + } + var order by rememberSaveable(state.defaultValues) { + mutableStateOf(state.defaultValues.order) + } + var time by rememberSaveable( + saver = LocalTimeStateSaver, + inputs = arrayOf(state.defaultValues) + ) { mutableStateOf(state.defaultValues.time) } + var color by rememberSaveable( + saver = ColorStateSaver, + inputs = arrayOf(state.defaultValues) + ) { mutableStateOf(state.defaultValues.color) } + + val requestMealTypeState = rememberTandoorRequestState() + + val form = remember { + KitshnForm( + sections = listOf( + KitshnFormSection( + listOf( + KitshnFormTextFieldItem( + value = { name }, + onValueChange = { name = it }, + label = { Text(stringResource(Res.string.common_name)) }, + leadingIcon = { + Icon( + Icons.AutoMirrored.Rounded.Label, + stringResource(Res.string.common_name) + ) + }, + optional = false, + check = { + if (it.length > 128) { + getString(Res.string.form_error_name_max_128) + } else if (it.isBlank()) { + getString(Res.string.form_error_field_empty) + } else { + null + } + } + ) + ) + ), + KitshnFormSection( + listOf( + KitshnFormTimeFieldItem( + value = { time }, + onValueChange = { time = it ?: LocalTime(12, 0) }, + label = { Text(stringResource(Res.string.common_time)) }, + leadingIcon = { + Icon( + Icons.Rounded.Timer, + stringResource(Res.string.common_time) + ) + }, + optional = false, + check = { null } + ), + KitshnFormColorFieldItem( + value = { color }, + onValueChange = { color = it ?: Color.Gray }, + label = { Text(stringResource(Res.string.common_color)) }, + leadingIcon = { + Icon( + Icons.Rounded.Palette, + stringResource(Res.string.common_color) + ) + }, + optional = false, + check = { null } + ) + ) + ) + ), + submitButton = { + Button(onClick = it) { + Text( + text = if (state.isEdit) { + stringResource(Res.string.action_save) + } else { + stringResource(Res.string.action_create) + } + ) + } + }, + onSubmit = { + coroutineScope.launch { + if (state.isEdit) { + val updated = requestMealTypeState.wrapRequest { + state.mealType?.partialUpdate( + client, + name = name, + order = order, + time = time, + color = color, + ) + } + if (updated != null){ + state.mealType = updated + client.container.mealType[updated.id] = updated + } + } else { + val created = requestMealTypeState.wrapRequest { + client.mealType.create( + name = name, + order = order, + time = time, + color = color, + ) + } + state.mealType = created + } + + hapticFeedback.handleTandoorRequestState(requestMealTypeState) + + if (state.mealType != null) { + onSaved(state.mealType!!) + } + state.dismiss() + + } + } + ) + } + + AdaptiveFullscreenDialog( + onDismiss = { + state.dismiss() + }, + title = { + Text( + text = if (state.isEdit) { + stringResource(Res.string.action_edit_meal_type) + } else { + stringResource(Res.string.action_add_meal_type) + } + ) + }, + topAppBarActions = { + if (state.isEdit) { + IconButton(onClick = { deleteDialogState.open(state.mealType!!) }) { + Icon(Icons.Rounded.Delete, stringResource(Res.string.action_delete)) + } + } + }, + actions = { + form.RenderSubmitButton() + } + ) { it, _, _ -> + form.Render(it) + } + + CommonDeletionDialog( + state = deleteDialogState, + onConfirm = { mealType -> + coroutineScope.launch { + requestMealTypeState.wrapRequest { + mealType.delete(client) + } + + client.container.mealType.remove(mealType.id) + + hapticFeedback.handleTandoorRequestState(requestMealTypeState) + + onDeleted(mealType) + state.dismiss() + } + } + ) + + TandoorRequestErrorHandler(state = requestMealTypeState) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/state/ComposeSavers.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/state/ComposeSavers.kt new file mode 100644 index 00000000..9bf6629e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/state/ComposeSavers.kt @@ -0,0 +1,39 @@ +package de.kitshn.ui.state + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.ui.graphics.Color +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime + +fun mutableStateSaver(innerElementSaver: Saver) = Saver, Any>( + save = { state -> + with(innerElementSaver as Saver) { save(state.value) } ?: "null" + }, + restore = { value -> + if (value == "null") throw IllegalArgumentException("Cannot restore null value") + else mutableStateOf((innerElementSaver as Saver).restore(value)!!) + } +) + +val ColorSaver = Saver( + save = { it.value.toLong() }, + restore = { Color(it.toULong()) } +) + +val ColorStateSaver = mutableStateSaver(ColorSaver) + +val LocalTimeSaver = Saver( + save = { it.toString() }, + restore = { LocalTime.parse(it) } +) + +val LocalTimeStateSaver = mutableStateSaver(LocalTimeSaver) + +val LocalDateSaver = Saver( + save = { it.toString() }, + restore = { LocalDate.parse(it) } +) + +val LocalDateStateSaver = mutableStateSaver(LocalDateSaver) diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/recipe/details/RecipeDetails.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/recipe/details/RecipeDetails.kt index 2e59005d..16050c2c 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/recipe/details/RecipeDetails.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/recipe/details/RecipeDetails.kt @@ -591,7 +591,8 @@ fun ViewRecipeDetails( mealPlanCreationDialogState.open( MealPlanCreationAndEditDefaultValues( recipeId = recipeOverview.id, - servings = recipeOverview.servings.toDouble() + servings = recipeOverview.servings.toDouble(), + mealTypeId = userPreference.default_meal_type?.id ) ) }