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