init the FixBlock Part

This commit is contained in:
boxi committed 2026-08-20 00:26:44 +02:00
1 parent 0da48ce865
commit 6d241ea58d
29 files changed
+2673 -359

No files matched your search

+49 -6
View File
@@ -68,8 +68,11 @@ Properties are **always frontmatter** — only the *default rendering* differs b
below is then just the property pills (due, priority, status).
- **Contact**: name in the header; a contact card — avatar, email/phone/mobile rows, address
with a **mini-map**.
- **Event**: on the left a date card + time range + all-day toggle; on the **right edge** the
location as a **mini-map with the place text beside it**.
- **Event**: on the left a date card + time range; on the **right edge** the location as a
**mini-map with the place text beside it**. **No all-day toggle here** — the wireframe drew
one, but it is a control for a value this state does not otherwise edit, and all-day is not an
independent fact anyway: it means there is no time, which the time range already shows. The
toggle lives in the field-edit state, where the times are actually set.
- **Location**: pills for coordinates, elevation (m ü. M.), and address (address optional,
hidden when absent), then a **full-width map** with the pin — then the Markdown body.
- **Track**: a **start/end block** — each endpoint shows coordinates + elevation, plus an
@@ -231,7 +234,13 @@ state the whole record's fields are open at once.
(Checkbox, Fällig and Priorität are *also* click-to-edit outside this state — this is where
they become a labelled form.)
- **Event**: date + time for start/end, **all-day toggle** (hides the time inputs),
location, **attendees as a tags editor**; recurrence/reminder optional.
location, **attendees as a tags editor**; recurrence/reminder optional. A time is **typed, not
picked** — four digits with the colon inserted for you beats any clock widget. The *date* does
get the calendar: the picker belongs to the value type, not to one editing mode, so it is the
same popover the inline pill offers.
- **A date may carry an optional time** (a task deadline, a user-defined attribute): the calendar
grows a time field below the grid. **Not for an event's start/end** — those are core fields with
their own editors, and a second place to set them would be a second truth for one value.
- **Location / Track (GIS)**: coordinates (lat/lon/elevation) editable **directly** *or*
**derived from an address** (explicit "derive" button) — both directions; the pin is
map-settable. A **track is edited via GPX upload** (dropzone / file-chip); its
@@ -295,14 +304,34 @@ platform UI sans (system-ui on web; the platform default in Compose).
| Role | Size | Weight | Face | Notes |
|---|---|---|---|---|
| Display L | 22 | 600 | display | record/page title |
| Record title | 24 | 600 | display | the body **H1**. Not redundant with Display L: that one is the *navigation* title in the header breadcrumb, this is the document heading |
| Display L | 22 | 600 | display | header/section title |
| Display M | 15–16 | 600 | display | breadcrumb current, section heads |
| Wordmark | 19 | 600 | display | letter-spacing .02em |
| Body | 14 | 400 | body | base; keep running text ≤ ~65ch |
| Body S | 13 | 400 | body | dense rows, inputs |
| Label / eyebrow | 10.5 | 600 | display | UPPERCASE, letter-spacing .08–.09em |
| Field label | 11 | 700 | display | UPPERCASE, letter-spacing .05em, in `text` — the bold label of the field-edit state. A **distinct role** from the eyebrow below: a record in that state shows many fields at once, and the weight is what keeps them scannable |
| Label / eyebrow | 10.5 | 600 | display | UPPERCASE, letter-spacing .08–.09em, in `muted` |
| Meta / caption | 11–12 | 400/600 | body/display | counts, tags, attribution |
### Component metrics settled while porting
The wireframes disagreed with themselves in about nine places. Rule applied throughout: **the
value from `record-view.html` / `record-edit.html` wins** — those show a component in a real
record, `design-system.html` shows it in an isolated demo. One exception, noted below.
| Component | Value | Loser |
|---|---|---|
| Task checkbox | 28 × 28, border 2, radius 8, check 18 | — |
| Contact avatar | 66 × 66, initials 24 | 60 / 22 (styleguide) |
| Contact row icon / label column | 30 × 30 tile, label column 64 | — |
| Date card | width 74, month strip on accent, day 24/600 | — |
| Mini-map | 150 × 104 (contact) · 170 × 104 (event) | 160 / 180 |
| Field input | border `line`, padding 8/10, focus ring `accent@15%` | `line-strong`, 9/11, 22% |
| Remove button | 32 × 32 | 30 × 30 |
| Panel column | 320 | 300 |
| Toggle | **36 × 21, knob 17** | 34 × 20 — *the exception*: phase 1 already shipped this size, and re-cutting a working control to match a demo is churn for nothing |
### Spacing, radii, elevation
- **Spacing** (4px base): `4 · 8 · 12 · 16 · 20 · 24 · 32 · 40`. Lay groups out with flex/grid
@@ -429,7 +458,21 @@ the *product* phase order lives in [`roadmap.md`](roadmap.md).
row**. Plus the runnable **`GalleryApp`** with a live theme + per-folder accent switch.
Components live in categorized `components/{button,control,nav,chip,tile,panel}` subpackages.
- **Phase 2 — type core blocks, inline attributes & basic field editors.** The per-type **core
- **Phase 2 — type core blocks, inline attributes & basic field editors (done).** Delivered in two
steps. **2a**: the inline value editor (the pill *is* the input; option list, calendar, plain
text), editable **property pill** / **property row**, the **inline title field**, and the
gallery's **left nav** with scroll-spy. **2b**: the **field editors** (text, select, date-time +
all-day, tags), the **"+ Feld"** affordance, and the **core blocks** for task and event in both
states. `:ui` gained Compose UI tests — focus and popup behaviour is where this editing model
actually lives, and it cannot be checked by reading the code.
- **The contact core block ships normal-state only.** Its field-edit state *is* the repeatable
labeled field (`[grip] [label] [value] [remove]`, drag-to-reorder because order is priority),
which phase 3 owns — building half of it here would only have to be undone.
- Deferred with it: the **geocode button** on the event location (phase 4, with the map seam),
and native date/time *pickers* in the form fields — the form inputs are typed text for now,
while the click-to-edit calendar exists inline.
Original scope of this phase: The per-type **core
blocks** (Task checkbox + pills, Contact card, Event date card + location) composed from the
phase-1 atoms and the mini-map placeholder, in **both states** (normal / field-edit); the
**inline value editor** + editable **property pill**/**property row** + **inline title field**
@@ -0,0 +1,234 @@
package terrata.ui.components.core
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import terrata.ui.components.field.AddFieldButton
import terrata.ui.components.field.AddableField
import terrata.ui.components.field.DashedButton
import terrata.ui.components.field.FieldEditor
import terrata.ui.components.field.FieldInput
import terrata.ui.components.tile.MiniMapPlaceholder
import terrata.ui.icons.TerrataIcons
import terrata.ui.preview.PreviewSurface
import terrata.ui.theme.TerrataTheme
private val AvatarSize = 66.dp
private val RowIconSize = 30.dp
private val RowLabelWidth = 64.dp
private val AddressMapSize = androidx.compose.ui.unit.DpSize(150.dp, 104.dp)
private val RowLabelEditWidth = 130.dp
private val RemoveButtonSize = 32.dp
/** One `[icon] [label] [value]` line of the contact card. */
public data class ContactLine(val icon: ImageVector, val label: String, val value: String)
/** Every word the contact block shows. Supplied by the caller — the component holds no language. */
public data class ContactCoreLabels(
val contactDetails: String = "",
val addRow: String = "",
val labelPlaceholder: String? = null,
val valuePlaceholder: String? = null,
val remove: String? = null,
val addField: String = "",
val addFieldMenuTitle: String? = null,
)
@Composable
private fun ContactFields(
lines: List<ContactLine>,
onLinesChange: (List<ContactLine>) -> Unit,
labels: ContactCoreLabels,
optionalFields: List<AddableField>,
onAddField: (AddableField) -> Unit,
) {
val c = TerrataTheme.colors
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
FieldEditor(labels.contactDetails) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
lines.forEachIndexed { index, line ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.width(RowLabelEditWidth)) {
FieldInput(
value = line.label,
onValueChange = { onLinesChange(lines.replaceAt(index, line.copy(label = it))) },
placeholder = labels.labelPlaceholder,
)
}
Box(Modifier.weight(1f)) {
FieldInput(
value = line.value,
onValueChange = { onLinesChange(lines.replaceAt(index, line.copy(value = it))) },
placeholder = labels.valuePlaceholder,
)
}
Box(
modifier = Modifier
.size(RemoveButtonSize)
.clip(MaterialTheme.shapes.small)
.border(1.dp, c.line, MaterialTheme.shapes.small)
.clickable { onLinesChange(lines.filterIndexed { i, _ -> i != index }) },
contentAlignment = Alignment.Center,
) {
Icon(
TerrataIcons.Close,
contentDescription = labels.remove,
tint = c.muted,
modifier = Modifier.size(14.dp),
)
}
}
}
DashedButton(
label = labels.addRow,
onClick = { onLinesChange(lines + ContactLine(TerrataIcons.Mail, "", "")) },
)
}
}
if (optionalFields.isNotEmpty()) {
AddFieldButton(labels.addField, optionalFields, onAddField, menuTitle = labels.addFieldMenuTitle)
}
}
}
private fun <T> List<T>.replaceAt(index: Int, value: T): List<T> =
mapIndexed { i, existing -> if (i == index) value else existing }
/**
* The contact's base-schema fields: avatar, the labelled contact lines, and the primary address
* next to a map crop.
*
* In the field-edit state the same fields become labelled editors, with the contact lines as
* `[label] [value] [remove]` rows plus an add button — order is priority, so the first entry is
* the primary one. **Drag-to-reorder and label autocomplete are not here yet**: both belong to the
* repeatable-field work in the block-editor phase, and they are additive on top of these rows
* rather than a different shape. See docs/design/ui-ux.md, "Compose build-out phases".
*/
@Composable
public fun ContactCard(
initials: String,
lines: List<ContactLine>,
modifier: Modifier = Modifier,
addressLabel: String? = null,
addressLines: List<String> = emptyList(),
fieldEdit: Boolean = false,
onLinesChange: (List<ContactLine>) -> Unit = {},
labels: ContactCoreLabels = ContactCoreLabels(),
optionalFields: List<AddableField> = emptyList(),
onAddField: (AddableField) -> Unit = {},
) {
val c = TerrataTheme.colors
if (fieldEdit) {
CoreBlock(modifier) {
ContactFields(lines, onLinesChange, labels, optionalFields, onAddField)
}
return
}
CoreBlock(modifier) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Box(
Modifier.size(AvatarSize).clip(CircleShape).background(c.accent),
contentAlignment = Alignment.Center,
) {
Text(initials, style = MaterialTheme.typography.headlineSmall, color = Color.White)
}
Column(
modifier = Modifier.widthIn(min = 220.dp),
verticalArrangement = Arrangement.spacedBy(9.dp),
) {
lines.forEach { line -> ContactRow(line) }
if (addressLines.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
MiniMapPlaceholder(
Modifier
.size(AddressMapSize)
.clip(MaterialTheme.shapes.small)
.border(1.dp, c.line, MaterialTheme.shapes.small),
)
Column(verticalArrangement = Arrangement.Center) {
if (addressLabel != null) {
Text(
addressLabel.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = c.muted,
)
}
addressLines.forEach {
Text(it, style = MaterialTheme.typography.bodyMedium, color = c.text)
}
}
}
}
}
}
}
}
@Composable
private fun ContactRow(line: ContactLine) {
val c = TerrataTheme.colors
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(11.dp)) {
Box(
Modifier
.size(RowIconSize)
.clip(MaterialTheme.shapes.small)
.background(c.surface2)
.border(1.dp, c.line, MaterialTheme.shapes.small),
contentAlignment = Alignment.Center,
) {
Icon(line.icon, contentDescription = null, tint = c.muted, modifier = Modifier.size(15.dp))
}
Text(
line.label.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = c.muted,
modifier = Modifier.width(RowLabelWidth),
)
Text(line.value, style = MaterialTheme.typography.bodyMedium, color = c.text)
}
}
@Preview
@Composable
private fun ContactCardPreview() {
PreviewSurface {
Box(Modifier.width(560.dp)) {
ContactCard(
initials = "AM",
lines = listOf(
ContactLine(TerrataIcons.Mail, "E-Mail", "anna.muster@example.com"),
ContactLine(TerrataIcons.Phone, "Tel", "+41 44 123 45 67"),
ContactLine(TerrataIcons.Phone, "Mobil", "+41 79 987 65 43"),
),
addressLabel = "Adresse",
addressLines = listOf("Bahnhofstrasse 1", "8001 Zürich"),
)
}
}
}
@@ -0,0 +1,46 @@
package terrata.ui.components.core
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import terrata.ui.theme.TerrataTheme
/**
* The frame every type's core block shares: content, then a rule separating it from the Markdown
* body below.
*
* The block **is** the type's base-schema fields. All type variance lives here in the body — which
* is exactly what lets the record header stay a generic component with no `if (type…)` in it
* (docs/design/ui-ux.md).
*/
@Composable
public fun CoreBlock(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
val c = TerrataTheme.colors
Column(modifier.fillMaxWidth()) {
Box(Modifier.padding(bottom = 18.dp)) { content() }
Box(Modifier.fillMaxWidth().height(1.dp).background(c.line))
}
}
/**
* A row of labelled fields in the field-edit state. Children carry their own `Modifier.weight`,
* which is how a caller decides whether it wants two equal columns or something else — the two
* columns of the wireframe are a layout choice, not a rule.
*/
@Composable
public fun FieldRow(modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {
Row(
modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
content = content,
)
}
@@ -0,0 +1,260 @@
package terrata.ui.components.core
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import kotlinx.datetime.LocalDate
import terrata.ui.components.edit.InlineEditorStrings
import terrata.ui.components.field.AddFieldButton
import terrata.ui.components.field.AddableField
import terrata.ui.components.field.AllDayToggle
import terrata.ui.components.field.DateTimeEditor
import terrata.ui.components.field.FieldEditor
import terrata.ui.components.field.FieldInput
import terrata.ui.components.field.TagsEditor
import terrata.ui.components.tile.MiniMapPlaceholder
import terrata.ui.preview.PreviewSurface
import terrata.ui.theme.TerrataTheme
private val DateCardWidth = 74.dp
private val LocationMapSize = DpSize(170.dp, 104.dp)
/** Every word the event block shows. Supplied by the caller — the component holds no language. */
public data class EventCoreLabels(
val start: String,
val end: String,
val allDay: String,
val location: String,
val attendees: String,
val addField: String = "",
val addFieldMenuTitle: String? = null,
val attendeePlaceholder: String? = null,
)
/** What the date card shows: already-formatted parts, because month names are the caller's. */
public data class EventDate(val month: String, val day: String, val weekday: String)
/**
* The event's base-schema fields. In the normal state the location sits on the **right edge** —
* text with the map beside it — mirroring the contact card, where the map leads (docs/design/ui-ux.md).
*
* The normal state carries **no all-day switch**. The wireframes showed one, but it is a control
* for a value that state does not otherwise edit — and all-day is not an independent fact anyway:
* it simply means there is no time. Whether the event lasts all day is therefore already visible
* in [timeRange]. The switch belongs to the field-edit state, where the times are actually set.
*/
@Composable
@Suppress("LongParameterList")
public fun EventCoreBlock(
date: EventDate,
timeRange: String,
allDay: Boolean,
onAllDayChange: (Boolean) -> Unit,
location: String,
onLocationChange: (String) -> Unit,
labels: EventCoreLabels,
today: LocalDate,
modifier: Modifier = Modifier,
strings: InlineEditorStrings = InlineEditorStrings(),
fieldEdit: Boolean = false,
locationLines: List<String> = emptyList(),
startDate: String = "",
onStartDateChange: (String) -> Unit = {},
startTime: String = "",
onStartTimeChange: (String) -> Unit = {},
endDate: String = "",
onEndDateChange: (String) -> Unit = {},
endTime: String = "",
onEndTimeChange: (String) -> Unit = {},
attendees: List<String> = emptyList(),
onAttendeesChange: (List<String>) -> Unit = {},
optionalFields: List<AddableField> = emptyList(),
onAddField: (AddableField) -> Unit = {},
) {
CoreBlock(modifier) {
if (fieldEdit) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
FieldRow {
Box(Modifier.weight(1f)) {
FieldEditor(labels.start) {
DateTimeEditor(
date = startDate,
onDateChange = onStartDateChange,
time = startTime,
onTimeChange = onStartTimeChange,
allDay = allDay,
today = today,
strings = strings,
)
}
}
Box(Modifier.weight(1f)) {
FieldEditor(labels.end) {
DateTimeEditor(
date = endDate,
onDateChange = onEndDateChange,
time = endTime,
onTimeChange = onEndTimeChange,
allDay = allDay,
today = today,
strings = strings,
)
}
}
}
AllDayToggle(allDay, onAllDayChange, labels.allDay)
FieldEditor(labels.location) { FieldInput(location, onLocationChange) }
FieldEditor(labels.attendees) {
TagsEditor(attendees, onAttendeesChange, placeholder = labels.attendeePlaceholder)
}
if (optionalFields.isNotEmpty()) {
AddFieldButton(labels.addField, optionalFields, onAddField, menuTitle = labels.addFieldMenuTitle)
}
}
} else {
EventSummary(date, timeRange, labels, locationLines)
}
}
}
@Composable
private fun EventSummary(date: EventDate, timeRange: String, labels: EventCoreLabels, locationLines: List<String>) {
val c = TerrataTheme.colors
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(22.dp),
verticalArrangement = Arrangement.spacedBy(22.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
Row(horizontalArrangement = Arrangement.spacedBy(22.dp)) {
DateCard(date)
Text(timeRange, style = MaterialTheme.typography.titleMedium, color = c.text)
}
if (locationLines.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(labels.location.uppercase(), style = MaterialTheme.typography.labelSmall, color = c.muted)
locationLines.forEach {
Text(it, style = MaterialTheme.typography.bodyMedium, color = c.text)
}
}
MiniMapPlaceholder(
Modifier
.size(LocationMapSize)
.clip(MaterialTheme.shapes.small)
.border(1.dp, c.line, MaterialTheme.shapes.small),
)
}
}
}
}
@Composable
private fun DateCard(date: EventDate) {
val c = TerrataTheme.colors
Column(
modifier = Modifier
.width(DateCardWidth)
.clip(MaterialTheme.shapes.small)
.background(c.surface)
.border(1.dp, c.line, MaterialTheme.shapes.small),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
date.month.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().background(c.accent).padding(vertical = 3.dp),
)
Text(
date.day,
style = MaterialTheme.typography.headlineSmall,
color = c.text,
modifier = Modifier.padding(top = 6.dp, bottom = 2.dp),
)
Text(
date.weekday,
style = MaterialTheme.typography.bodySmall,
color = c.muted,
modifier = Modifier.padding(bottom = 6.dp),
)
}
}
private val PreviewLabels = EventCoreLabels(
start = "Beginn",
end = "Ende",
allDay = "Ganztägig",
location = "Ort",
attendees = "Teilnehmer",
addField = "Feld hinzufügen",
addFieldMenuTitle = "Weitere Termin-Felder",
attendeePlaceholder = "Name hinzufügen…",
)
@Preview
@Composable
private fun EventCoreBlockPreview() {
PreviewSurface {
var allDay by remember { mutableStateOf(false) }
var attendees by remember { mutableStateOf(listOf("Anna", "Boxi")) }
Column(Modifier.width(680.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
EventCoreBlock(
date = EventDate("Aug", "05", "Mi"),
timeRange = "09:00 – 10:00",
allDay = allDay,
onAllDayChange = { allDay = it },
location = "Ergon, Zürich",
onLocationChange = {},
labels = PreviewLabels,
today = LocalDate(2026, 8, 20),
locationLines = listOf("Ergon", "Zürich"),
)
EventCoreBlock(
date = EventDate("Aug", "05", "Mi"),
timeRange = "09:00 – 10:00",
allDay = allDay,
onAllDayChange = { allDay = it },
location = "Ergon, Zürich",
onLocationChange = {},
labels = PreviewLabels,
today = LocalDate(2026, 8, 20),
fieldEdit = true,
startDate = "05.08.2026",
startTime = "09:00",
endDate = "05.08.2026",
endTime = "10:00",
attendees = attendees,
onAttendeesChange = { attendees = it },
optionalFields = listOf(AddableField("Wiederholung", "Serie")),
)
}
}
}
@@ -0,0 +1,137 @@
package terrata.ui.components.core
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.datetime.LocalDate
import terrata.ui.components.chip.EditablePropertyPill
import terrata.ui.components.edit.InlineEditorStrings
import terrata.ui.components.edit.InlineValueKind
import terrata.ui.components.field.AddFieldButton
import terrata.ui.components.field.AddableField
import terrata.ui.components.field.DateInput
import terrata.ui.components.field.FieldEditor
import terrata.ui.components.field.SelectEditor
import terrata.ui.preview.PreviewSurface
/**
* The task's base-schema fields. The done-checkbox is **not** here — it sits before the body H1
* (see `InlineTitleField`), Todoist-style, which leaves this block as due + priority alone.
*
* Both states, one composable: [fieldEdit] turns the pills into labelled form fields. Two
* composables would let the two drift apart, and it is the same fields either way.
*
* Values are display strings, not domain types: `:core:model` enums deliberately carry no labels,
* so a component taking them would need a label mapping passed in anyway. Mapping domain to text
* is the app's job — and later the localization's.
*/
@Composable
public fun TaskCoreBlock(
due: String,
onDueChange: (String) -> Unit,
priority: String,
onPriorityChange: (String) -> Unit,
labels: TaskCoreLabels,
today: LocalDate,
modifier: Modifier = Modifier,
fieldEdit: Boolean = false,
strings: InlineEditorStrings = InlineEditorStrings(),
optionalFields: List<AddableField> = emptyList(),
onAddField: (AddableField) -> Unit = {},
) {
CoreBlock(modifier) {
if (fieldEdit) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
FieldRow {
Box(Modifier.weight(1f)) {
FieldEditor(labels.due) {
DateInput(due, onDueChange, today, strings, withTime = true)
}
}
Box(Modifier.weight(1f)) {
FieldEditor(labels.priority) {
SelectEditor(priority, labels.priorityOptions, onPriorityChange)
}
}
}
if (optionalFields.isNotEmpty()) {
AddFieldButton(labels.addField, optionalFields, onAddField, menuTitle = labels.addFieldMenuTitle)
}
}
} else {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
EditablePropertyPill(
label = labels.due,
value = due,
onValueChange = onDueChange,
// A deadline may name an hour; an event's start may not, because that is a
// core field with its own editor.
kind = InlineValueKind.Date(today, withTime = true),
strings = strings,
)
EditablePropertyPill(
label = labels.priority,
value = priority,
onValueChange = onPriorityChange,
kind = InlineValueKind.Options(labels.priorityOptions),
strings = strings,
accentBadge = true,
)
}
}
}
}
/** Every word the task block shows. Supplied by the caller — the component holds no language. */
public data class TaskCoreLabels(
val due: String,
val priority: String,
val priorityOptions: List<String>,
val addField: String = "",
val addFieldMenuTitle: String? = null,
)
private val PreviewLabels = TaskCoreLabels(
due = "Fällig",
priority = "Priorität",
priorityOptions = listOf("Keine", "Niedrig", "Mittel", "Hoch"),
addField = "Feld hinzufügen",
addFieldMenuTitle = "Weitere Task-Felder",
)
@Preview
@Composable
private fun TaskCoreBlockPreview() {
PreviewSurface {
var due by remember { mutableStateOf("02.08.2026") }
var priority by remember { mutableStateOf("Hoch") }
Column(Modifier.width(520.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
TaskCoreBlock(due, { due = it }, priority, { priority = it }, PreviewLabels, LocalDate(2026, 8, 19))
TaskCoreBlock(
due = due,
onDueChange = { due = it },
priority = priority,
onPriorityChange = { priority = it },
labels = PreviewLabels,
today = LocalDate(2026, 8, 19),
fieldEdit = true,
optionalFields = listOf(AddableField("Status", "In Arbeit / Abgebrochen")),
)
}
}
}
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -31,6 +32,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import terrata.ui.components.field.TimeInput
import terrata.ui.icons.TerrataIcons
import terrata.ui.preview.PreviewSurface
import terrata.ui.theme.TerrataTheme
@@ -40,6 +42,7 @@ private val CellSize = 26.dp
private val CellHeight = 24.dp
private val CellShape = RoundedCornerShape(5.dp)
private val NavButtonSize = 22.dp
private val TimeFieldWidth = 84.dp
/**
* The picker for a date: a Monday-first month grid, today outlined, the current value in the
@@ -56,6 +59,8 @@ internal fun CalendarPopover(
onPick: (LocalDate) -> Unit,
strings: InlineEditorStrings,
modifier: Modifier = Modifier,
withTime: Boolean = false,
onTimeChange: (String) -> Unit = {},
) {
val c = TerrataTheme.colors
val selected = InlineValueLogic.parseDate(current)
@@ -102,6 +107,21 @@ internal fun CalendarPopover(
week.forEach { day -> DayCell(day, selected, today, onPick) }
}
}
if (withTime) {
Row(
modifier = Modifier.padding(top = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(strings.time.uppercase(), style = MaterialTheme.typography.labelSmall, color = c.muted)
TimeInput(
value = InlineValueLogic.timePart(typed),
onValueChange = onTimeChange,
modifier = Modifier.width(TimeFieldWidth),
)
}
}
}
}
@@ -20,4 +20,6 @@ public data class InlineEditorStrings(
val monthNames: List<String> = emptyList(),
/** Seven weekday abbreviations, **Monday first**. */
val weekdayNames: List<String> = emptyList(),
/** Label of the optional time field under the calendar, e.g. "Zeit". */
val time: String = "",
)
@@ -27,14 +27,12 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import terrata.ui.icons.TerrataIcons
import terrata.ui.preview.PreviewSurface
import terrata.ui.theme.TerrataTheme
private val CheckboxSize = 28.dp
private val CheckboxShape = RoundedCornerShape(8.dp)
private const val TITLE_SIZE_SP = 24
/**
* The body H1 — always editable, never behind a mode (docs/design/ui-ux.md). The optional task
@@ -83,8 +81,7 @@ public fun InlineTitleField(
onValueChange = onTitleChange,
singleLine = true,
cursorBrush = SolidColor(c.accent),
textStyle = MaterialTheme.typography.titleLarge.copy(
fontSize = TITLE_SIZE_SP.sp,
textStyle = MaterialTheme.typography.headlineSmall.copy(
color = if (done == true) c.muted else c.text,
textDecoration = if (done == true) TextDecoration.LineThrough else null,
),
@@ -28,6 +28,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
@@ -50,15 +51,9 @@ import kotlinx.datetime.LocalDate
import terrata.ui.preview.PreviewSurface
import terrata.ui.theme.TerrataTheme
/** Gap between the pill and whatever opens below it — the wireframe's 6px. */
private val PopoverGap = 6.dp
/** Lets the UI tests get hold of the floating layer, which is not addressable by its text. */
internal const val INLINE_POPOVER_TAG: String = "inline-popover"
private val PopoverMinWidth = 186.dp
private val PopoverPadding = 7.dp
/** Just enough width for the caret when the value has been emptied out. */
private val CaretRoom = 8.dp
@@ -92,6 +87,14 @@ internal fun InlineValueEditor(
// treating that as "clicked away" made the editor commit and close itself the instant it
// opened. Re-armed per edit, so a second click behaves like the first.
var everFocused by remember(editing) { mutableStateOf(false) }
// The option list and the key handler must agree on what is on screen, so the filtered list
// and the highlight live here rather than inside the popover.
val shown = if (kind is InlineValueKind.Options) {
InlineValueLogic.filterOptions(kind.options, draft.text, value)
} else {
emptyList()
}
var highlighted by remember(editing, draft.text) { mutableStateOf(shown.indexOf(value)) }
val current by rememberUpdatedState(value)
val focusRequester = remember { FocusRequester() }
val gap = with(LocalDensity.current) { PopoverGap.roundToPx() }
@@ -108,6 +111,13 @@ internal fun InlineValueEditor(
fun commitTyped() = commit(InlineValueLogic.commitFor(kind, draft.text, current))
/** Wraps, so a list opened on its last entry still reaches the first with one press. */
fun moveHighlight(delta: Int): Boolean {
if (shown.isEmpty()) return false
highlighted = (highlighted + delta).mod(shown.size)
return true
}
Box(modifier) {
if (!editing) {
Text(
@@ -151,47 +161,90 @@ internal fun InlineValueEditor(
}
.focusRequester(focusRequester)
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) {
false
} else {
when (event.key) {
Key.Enter, Key.NumPadEnter -> {
commitTyped()
true
}
Key.Escape -> {
stopEditing()
true
}
else -> false
}
}
handleEditorKey(
event = event,
onMove = ::moveHighlight,
onCommit = { shown.getOrNull(highlighted)?.let(::commit) ?: commitTyped() },
onCancel = ::stopEditing,
)
},
)
}
LaunchedEffect(Unit) { focusRequester.requestFocus() }
ValuePopover(kind, draft.text, current, strings, gap, onPick = ::commit, onDismiss = ::commitTyped)
ValuePopover(
state = PopoverState(kind, draft.text, current, shown, highlighted, strings),
gap = gap,
onPick = ::commit,
onTimeChange = { draft = draft.copy(text = withTimePart(draft.text, it)) },
onDismiss = ::commitTyped,
)
}
}
/**
* Keyboard and mouse stay live at the same time (docs/design/ui-ux.md): ↑/↓ walk the option list,
* ⏎ commits what is highlighted, ⎋ discards and restores the previous value.
*
* The highlight **wraps**, so a list opened on its last entry still reaches the first with one
* press — and it lives outside the composable so this stays a plain, readable decision table.
*/
private fun handleEditorKey(
event: KeyEvent,
onMove: (delta: Int) -> Boolean,
onCommit: () -> Unit,
onCancel: () -> Unit,
): Boolean = if (event.type != KeyEventType.KeyDown) {
false
} else {
when (event.key) {
Key.Enter, Key.NumPadEnter -> {
onCommit()
true
}
Key.Escape -> {
onCancel()
true
}
Key.DirectionDown -> onMove(1)
Key.DirectionUp -> onMove(-1)
else -> false
}
}
/** Replaces the time behind a date while the user edits it in the popover. */
private fun withTimePart(text: String, time: String): String {
val datePart = text.trim().substringBefore(' ')
return if (time.isEmpty()) datePart else "$datePart $time"
}
/** Everything the popover needs to draw itself, so the call stays readable. */
private class PopoverState(
val kind: InlineValueKind,
val typed: String,
val current: String,
val shown: List<String>,
val highlighted: Int,
val strings: InlineEditorStrings,
)
/**
* What hangs below the pill while editing — nothing at all for plain text, which is the point:
* a text attribute needs no picker, so opening one would be a step for nothing.
*/
@Composable
private fun ValuePopover(
kind: InlineValueKind,
typed: String,
current: String,
strings: InlineEditorStrings,
state: PopoverState,
gap: Int,
onPick: (String) -> Unit,
onTimeChange: (String) -> Unit,
onDismiss: () -> Unit,
) {
if (kind is InlineValueKind.PlainText) return
if (state.kind is InlineValueKind.PlainText) return
val c = TerrataTheme.colors
Popup(
popupPositionProvider = remember(gap) { BelowAnchor(gap) },
@@ -201,28 +254,27 @@ private fun ValuePopover(
// field keeps the caret.
properties = PopupProperties(focusable = false, dismissOnClickOutside = true),
) {
Box(
Modifier
.testTag(INLINE_POPOVER_TAG)
// A popup is measured against the *window*, so any `fillMaxWidth` inside would
// stretch it edge to edge. Intrinsic width shrink-wraps it to its content while
// still letting rows fill that width.
.width(IntrinsicSize.Max)
.widthIn(min = PopoverMinWidth)
.background(c.surface, MaterialTheme.shapes.small)
.border(1.dp, c.lineStrong, MaterialTheme.shapes.small)
.padding(PopoverPadding),
) {
when (kind) {
is InlineValueKind.Options ->
OptionListPopover(kind.options, typed, current, onPick, strings)
PopoverSurface {
when (val kind = state.kind) {
is InlineValueKind.Options -> OptionListPopover(
shown = state.shown,
highlighted = state.highlighted,
current = state.current,
onPick = onPick,
strings = state.strings,
)
is InlineValueKind.Date -> CalendarPopover(
typed = typed,
current = current,
typed = state.typed,
current = state.current,
today = kind.today,
onPick = { onPick(InlineValueLogic.formatDate(it)) },
strings = strings,
onPick = { picked ->
// Picking a day must not throw away a time that was already entered.
onPick(InlineValueLogic.formatDateTime(picked, InlineValueLogic.timePart(state.typed)))
},
strings = state.strings,
withTime = kind.withTime,
onTimeChange = onTimeChange,
)
is InlineValueKind.PlainText -> Unit
@@ -243,28 +295,3 @@ private fun InlineValueEditorPreview() {
}
}
}
/**
* Puts the popover directly under the attribute it belongs to.
*
* The `Popup(alignment, offset)` overload aligns against the **window**, not the anchor — which
* parked every popover in the window's corner, on top of the pill it was supposed to serve. Only
* a position provider sees `anchorBounds`.
*
* Flips above the anchor when there is no room below, because clamping to the window edge would
* put it back over the field the user is typing into.
*/
private class BelowAnchor(private val gap: Int) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val maxX = (windowSize.width - popupContentSize.width).coerceAtLeast(0)
val below = anchorBounds.bottom + gap
val fitsBelow = below + popupContentSize.height <= windowSize.height
val y = if (fitsBelow) below else (anchorBounds.top - gap - popupContentSize.height).coerceAtLeast(0)
return IntOffset(anchorBounds.left.coerceIn(0, maxX), y)
}
}
@@ -19,6 +19,10 @@ public sealed interface InlineValueKind {
*
* [today] is passed in rather than read from a clock so previews and tests render a fixed
* month instead of drifting with the calendar.
*
* [withTime] adds a time field under the grid — a task deadline or a user-defined attribute
* may want one. An event does **not**: its start and end times are core fields with their own
* editors, and offering a second place to set them would create two truths for one value.
*/
public data class Date(val today: LocalDate) : InlineValueKind
public data class Date(val today: LocalDate, val withTime: Boolean = false) : InlineValueKind
}
@@ -37,9 +37,12 @@ internal object InlineValueLogic {
return if (shown.size == 1) shown.single() else original
}
/** Accepts `14.9.2026`, `14.09.2026` and `2026-09-14`; `null` for anything else. */
/**
* Accepts `14.9.2026`, `14.09.2026` and `2026-09-14`, with or without a trailing time;
* `null` for anything else.
*/
fun parseDate(text: String): LocalDate? {
val trimmed = text.trim()
val trimmed = text.trim().substringBefore(' ')
val parts = DAY_MONTH_YEAR.matchEntire(trimmed)?.destructured?.let { (day, month, year) ->
Triple(year, month, day)
} ?: ISO.matchEntire(trimmed)?.destructured?.let { (year, month, day) ->
@@ -56,13 +59,29 @@ internal object InlineValueLogic {
*/
fun commitFor(kind: InlineValueKind, typed: String, current: String): String = when (kind) {
is InlineValueKind.PlainText -> typed.trim()
is InlineValueKind.Options -> commitOption(kind.options, typed, current)
is InlineValueKind.Date -> parseDate(typed)?.let(::formatDate) ?: current
is InlineValueKind.Date -> {
val date = parseDate(typed)
when {
date == null -> current
!kind.withTime -> formatDate(date)
else -> formatDateTime(date, TimeText.format(timePart(typed)).takeIf(TimeText::isComplete).orEmpty())
}
}
}
/** The display form, `DD.MM.YYYY`. Storage stays ISO — this is for the pill only. */
fun formatDate(date: LocalDate): String = "${date.day.pad()}.${date.monthNumber().pad()}.${date.year}"
/** The time written after a date, or `""` when there is none. */
fun timePart(text: String): String = text.trim().substringAfter(' ', "").trim()
/** `DD.MM.YYYY`, plus ` HH:MM` when a time is given. */
fun formatDateTime(date: LocalDate, time: String): String =
if (time.isEmpty()) formatDate(date) else "${formatDate(date)} $time"
private fun localDateOrNull(year: Int, month: Int, day: Int): LocalDate? =
runCatching { LocalDate(year, month, day) }.getOrNull()
@@ -28,22 +28,25 @@ private val OptionListMaxHeight = 198.dp
private val OptionShape = RoundedCornerShape(6.dp)
/**
* The picker for a closed set of values: filters as you type, and marks which entry is the
* current one so the list stays readable when everything still matches.
* The picker for a closed set of values.
*
* It renders what it is handed rather than filtering itself: the key handler in the editor has to
* walk exactly the rows the user can see, so the filtered list and the highlight are computed once,
* upstream. The *highlight* (keyboard position) and the *current value* marker are two different
* things and are drawn as such.
*
* All display text arrives via [strings] — this component holds no language of its own.
*/
@Composable
internal fun OptionListPopover(
options: List<String>,
typed: String,
original: String,
shown: List<String>,
highlighted: Int,
current: String,
onPick: (String) -> Unit,
strings: InlineEditorStrings,
modifier: Modifier = Modifier,
) {
val c = TerrataTheme.colors
val shown = InlineValueLogic.filterOptions(options, typed, original)
if (shown.isEmpty()) {
Text(
@@ -60,13 +63,14 @@ internal fun OptionListPopover(
modifier = modifier.heightIn(max = OptionListMaxHeight).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
shown.forEach { option ->
val isCurrent = option.equals(original, ignoreCase = true)
shown.forEachIndexed { index, option ->
val isCurrent = option.equals(current, ignoreCase = true)
val isHighlighted = index == highlighted
Row(
modifier = Modifier
.fillMaxWidth()
.clip(OptionShape)
.background(if (isCurrent) c.surface2 else c.surface)
.background(if (isHighlighted) c.surface2 else c.surface)
.clickable { onPick(option) }
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -92,9 +96,9 @@ internal fun OptionListPopover(
private fun OptionListPopoverPreview() {
PreviewSurface {
OptionListPopover(
options = listOf("Keine", "Niedrig", "Mittel", "Hoch"),
typed = "Hoch",
original = "Hoch",
shown = listOf("Keine", "Niedrig", "Mittel", "Hoch"),
highlighted = 1,
current = "Hoch",
onPick = {},
strings = InlineEditorStrings(currentMarker = "aktuell", noMatch = "Kein Wert passt"),
modifier = Modifier.width(200.dp),
@@ -0,0 +1,70 @@
package terrata.ui.components.edit
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupPositionProvider
import terrata.ui.theme.TerrataTheme
/** Gap between an anchor and whatever opens below it — the wireframe's 6px. */
internal val PopoverGap = 6.dp
internal val PopoverMinWidth = 186.dp
private val PopoverPadding = 7.dp
/**
* Puts a popover directly under the thing it belongs to.
*
* The `Popup(alignment, offset)` overload aligns against the **window**, not the anchor — which
* parked every popover in the window's corner, on top of the pill it was supposed to serve. Only
* a position provider sees `anchorBounds`.
*
* Flips above the anchor when there is no room below, because clamping to the window edge would
* put it back over the field the user is typing into.
*/
internal class BelowAnchor(private val gap: Int) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val maxX = (windowSize.width - popupContentSize.width).coerceAtLeast(0)
val below = anchorBounds.bottom + gap
val fitsBelow = below + popupContentSize.height <= windowSize.height
val y = if (fitsBelow) below else (anchorBounds.top - gap - popupContentSize.height).coerceAtLeast(0)
return IntOffset(anchorBounds.left.coerceIn(0, maxX), y)
}
}
/**
* The floating ground every popover shares.
*
* A popup is measured against the *window*, so any `fillMaxWidth` inside would stretch it edge to
* edge. Intrinsic width shrink-wraps it to its content while still letting rows fill that width.
*/
@Composable
internal fun PopoverSurface(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
val c = TerrataTheme.colors
Box(
modifier
.testTag(INLINE_POPOVER_TAG)
.width(IntrinsicSize.Max)
.widthIn(min = PopoverMinWidth)
.background(c.surface, MaterialTheme.shapes.small)
.border(1.dp, c.lineStrong, MaterialTheme.shapes.small)
.padding(PopoverPadding),
) { content() }
}
@@ -0,0 +1,39 @@
package terrata.ui.components.edit
private const val MAX_DIGITS = 4
private const val HOUR_DIGITS = 2
private const val MAX_HOUR = 23
private const val MAX_MINUTE = 59
/**
* Typing a time is typing four digits. Everything else — the colon, the length limit — is the
* field's job, not the user's.
*/
internal object TimeText {
/**
* Reduces whatever is in the field to at most four digits and puts the colon where it belongs.
*
* The colon appears with the **third** digit, not the second: inserting it as soon as two
* digits exist would produce `11:`, and since the formatter only ever looks at the digits,
* backspace could never get back past it — the caret would sit in front of a colon it cannot
* delete. Appearing one keystroke later costs nothing and stays reversible.
*/
fun format(raw: String): String {
val digits = raw.filter { it.isDigit() }.take(MAX_DIGITS)
return if (digits.length <= HOUR_DIGITS) {
digits
} else {
digits.substring(0, HOUR_DIGITS) + ":" + digits.substring(HOUR_DIGITS)
}
}
/** True for a complete, real time of day. An incomplete entry is not wrong, just unfinished. */
fun isComplete(text: String): Boolean {
val digits = text.filter { it.isDigit() }
if (digits.length != MAX_DIGITS) return false
val hour = digits.substring(0, HOUR_DIGITS).toInt()
val minute = digits.substring(HOUR_DIGITS).toInt()
return hour <= MAX_HOUR && minute <= MAX_MINUTE
}
}
@@ -0,0 +1,154 @@
package terrata.ui.components.field
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
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.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.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import terrata.ui.icons.TerrataIcons
import terrata.ui.preview.PreviewSurface
import terrata.ui.theme.TerrataTheme
private fun Modifier.dashedBorder(color: Color, shape: Shape): Modifier = drawBehind {
val dash = 4.dp.toPx()
drawOutline(
outline = shape.createOutline(size, layoutDirection, this),
color = color,
style = Stroke(width = 1.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(dash, dash))),
)
}
/**
* A dashed "+ something" button. Dashed on purpose: it adds *structure*, it is not a value the
* record holds — the same reason the wireframe draws it that way.
*/
@Composable
public fun DashedButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier, active: Boolean = false) {
val c = TerrataTheme.colors
val tint = if (active) c.accent else c.muted
Row(
modifier = modifier
.clip(MaterialTheme.shapes.small)
.dashedBorder(if (active) c.accent else c.lineStrong, MaterialTheme.shapes.small)
.clickable(onClick = onClick)
.padding(horizontal = 13.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(7.dp),
) {
Icon(TerrataIcons.Plus, contentDescription = null, tint = tint, modifier = Modifier.size(13.dp))
Text(label, style = MaterialTheme.typography.bodySmall, color = tint)
}
}
/** One entry of the "+ Feld" menu: what to add, and what kind of editor it brings. */
public data class AddableField(val label: String, val description: String? = null)
/**
* Reveals a type's remaining optional base fields.
*
* It exists because **"+ Feld" needs somewhere to live**: base schemas — contact and event above
* all — carry many optional fields that stay hidden while empty, and a per-field toggle would only
* relocate the problem (docs/design/ui-ux.md). Dashed on purpose: it adds structure, it is not a
* value.
*/
@Composable
public fun AddFieldButton(
label: String,
fields: List<AddableField>,
onAdd: (AddableField) -> Unit,
modifier: Modifier = Modifier,
menuTitle: String? = null,
) {
val c = TerrataTheme.colors
var open by remember { mutableStateOf(false) }
Box(modifier) {
DashedButton(label = label, onClick = { open = !open }, active = open)
if (open) {
Popup(onDismissRequest = { open = false }, properties = PopupProperties(focusable = true)) {
Column(
Modifier
.width(240.dp)
.clip(MaterialTheme.shapes.small)
.background(c.surface)
.border(1.dp, c.lineStrong, MaterialTheme.shapes.small)
.padding(6.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
if (menuTitle != null) {
Text(
menuTitle.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = c.muted,
modifier = Modifier.padding(start = 10.dp, top = 4.dp, bottom = 6.dp),
)
}
fields.forEach { field ->
Column(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.clickable {
onAdd(field)
open = false
}
.padding(horizontal = 10.dp, vertical = 7.dp),
) {
Text(field.label, style = MaterialTheme.typography.bodySmall, color = c.text)
if (field.description != null) {
Text(
field.description,
style = MaterialTheme.typography.bodySmall,
color = c.muted,
)
}
}
}
}
}
}
}
}
@Preview
@Composable
private fun AddFieldButtonPreview() {
PreviewSurface {
AddFieldButton(
label = "Feld hinzufügen",
menuTitle = "Weitere Task-Felder",
fields = listOf(
AddableField("Status", "In Arbeit / Abgebrochen"),
AddableField("Wiederholung", "Serie"),
AddableField("Erinnerung"),
),
onAdd = {},
)
}
}
@@ -0,0 +1,121 @@
package terrata.ui.components.field
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import kotlinx.datetime.LocalDate
import terrata.ui.components.edit.BelowAnchor
import terrata.ui.components.edit.CalendarPopover
import terrata.ui.components.edit.InlineEditorStrings
import terrata.ui.components.edit.InlineValueLogic
import terrata.ui.components.edit.PopoverGap
import terrata.ui.components.edit.PopoverSurface
import terrata.ui.icons.TerrataIcons
import terrata.ui.preview.PreviewSurface
import terrata.ui.theme.TerrataTheme
/**
* A date field with the same calendar the inline pill offers.
*
* The picker belongs to the *value type*, not to one editing mode — a date is no easier to type
* correctly inside a form than inside a pill. Opened by the calendar button rather than by focus,
* so typing a date stays possible without a popover in the way.
*/
@Composable
public fun DateInput(
value: String,
onValueChange: (String) -> Unit,
today: LocalDate,
strings: InlineEditorStrings,
modifier: Modifier = Modifier,
withTime: Boolean = false,
placeholder: String? = null,
) {
val c = TerrataTheme.colors
var open by remember { mutableStateOf(false) }
val gap = with(LocalDensity.current) { PopoverGap.roundToPx() }
Box(modifier) {
FieldInput(
value = value,
onValueChange = onValueChange,
placeholder = placeholder,
trailing = {
Box(
modifier = Modifier
.size(24.dp)
.clip(MaterialTheme.shapes.small)
.background(if (open) c.surface2 else Color.Transparent)
.clickable { open = !open },
contentAlignment = Alignment.Center,
) {
Icon(
TerrataIcons.Calendar,
contentDescription = null,
tint = if (open) c.accent else c.muted,
modifier = Modifier.size(15.dp),
)
}
},
)
if (open) {
Popup(
popupPositionProvider = remember(gap) { BelowAnchor(gap) },
onDismissRequest = { open = false },
properties = PopupProperties(focusable = true, dismissOnClickOutside = true),
) {
PopoverSurface {
CalendarPopover(
typed = value,
current = value,
today = today,
onPick = { picked ->
onValueChange(InlineValueLogic.formatDateTime(picked, InlineValueLogic.timePart(value)))
open = false
},
strings = strings,
withTime = withTime,
onTimeChange = { time ->
val datePart = value.trim().substringBefore(' ')
onValueChange(if (time.isEmpty()) datePart else "$datePart $time")
},
)
}
}
}
}
}
@Preview
@Composable
private fun DateInputPreview() {
PreviewSurface {
Box(Modifier.size(width = 260.dp, height = 80.dp)) {
FieldEditor("Fällig") {
DateInput(
value = "02.08.2026",
onValueChange = {},
today = LocalDate(2026, 8, 20),
strings = InlineEditorStrings(),
)
}
}
}
}
@@ -0,0 +1,93 @@
package terrata.ui.components.field
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.datetime.LocalDate
import terrata.ui.components.control.Toggle
import terrata.ui.components.edit.InlineEditorStrings
import terrata.ui.preview.PreviewSurface
private val TimeFieldWidth = 120.dp
/**
* Start/end as date + time, with the all-day toggle that hides the time inputs.
*
* All-day is not a separate truth: it simply means there is no time part, which is why hiding the
* inputs *is* the whole behaviour (docs/design/base-schemas.md).
*/
@Composable
public fun DateTimeEditor(
date: String,
onDateChange: (String) -> Unit,
time: String,
onTimeChange: (String) -> Unit,
allDay: Boolean,
today: LocalDate,
modifier: Modifier = Modifier,
strings: InlineEditorStrings = InlineEditorStrings(),
datePlaceholder: String? = null,
timePlaceholder: String? = null,
) {
Row(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) {
DateInput(
value = date,
onValueChange = onDateChange,
today = today,
strings = strings,
placeholder = datePlaceholder,
)
}
if (!allDay) {
Box(Modifier.widthIn(max = TimeFieldWidth)) {
TimeInput(time, onTimeChange, placeholder = timePlaceholder)
}
}
}
}
/** The "Ganztägig" switch that drives [DateTimeEditor]. Label supplied by the caller. */
@Composable
public fun AllDayToggle(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
label: String,
modifier: Modifier = Modifier,
) {
Toggle(checked = checked, onCheckedChange = onCheckedChange, label = label, modifier = modifier)
}
@Preview
@Composable
private fun DateTimeEditorPreview() {
PreviewSurface {
var allDay by remember { mutableStateOf(false) }
Column(Modifier.width(420.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Box(Modifier.weight(1f)) {
FieldEditor("Beginn") {
DateTimeEditor("05.08.2026", {}, "09:00", {}, allDay, LocalDate(2026, 8, 20))
}
}
Box(Modifier.weight(1f)) {
FieldEditor("Ende") {
DateTimeEditor("05.08.2026", {}, "10:00", {}, allDay, LocalDate(2026, 8, 20))
}
}
}
AllDayToggle(allDay, { allDay = it }, label = "Ganztägig")
}
}
}
@@ -0,0 +1,123 @@
package terrata.ui.components.field
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import terrata.ui.preview.PreviewSurface
import terrata.ui.theme.TerrataTheme
/** Ring drawn around a focused input — the wireframe's `0 0 0 3px accent@15%`. */
private val FocusRing = 3.dp
private const val FOCUS_RING_ALPHA = 0.15f
private const val FOCUS_BORDER_BLEND = 0.55f
/**
* A labelled field in the field-edit state: bold uppercase label over a typed input.
*
* The label is bold and in `text` rather than the muted eyebrow style — a record in this state
* shows many fields at once, and scanning them is what the weight buys (docs/design/ui-ux.md).
*/
@Composable
public fun FieldEditor(label: String, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
val c = TerrataTheme.colors
Column(modifier, verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(label.uppercase(), style = MaterialTheme.typography.labelMedium, color = c.text)
content()
}
}
/** A hint under a field — used where an editor is a preset now and grows a full UI later. */
@Composable
public fun FieldHint(text: String, modifier: Modifier = Modifier) {
val c = TerrataTheme.colors
Text(text, style = MaterialTheme.typography.bodySmall, color = c.muted, modifier = modifier)
}
/**
* The plain text input every other field editor is built from. Its own composable so the border,
* the focus ring and the metrics exist exactly once.
*/
@Composable
public fun FieldInput(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String? = null,
enabled: Boolean = true,
trailing: (@Composable () -> Unit)? = null,
visualTransformation: VisualTransformation = VisualTransformation.None,
) {
val c = TerrataTheme.colors
var focused by remember { mutableStateOf(false) }
val border by animateColorAsState(
if (focused) c.accent.copy(alpha = FOCUS_BORDER_BLEND).compositeOver(c.line) else c.line,
label = "field-border",
)
Row(
modifier = modifier
.fillMaxWidth()
.then(
if (focused) {
Modifier.border(FocusRing, c.accent.copy(alpha = FOCUS_RING_ALPHA), MaterialTheme.shapes.small)
} else {
Modifier
},
)
.background(c.surface, MaterialTheme.shapes.small)
.border(1.dp, border, MaterialTheme.shapes.small)
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(Modifier.weight(1f)) {
if (value.isEmpty() && placeholder != null) {
Text(placeholder, style = MaterialTheme.typography.bodyMedium, color = c.muted)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
enabled = enabled,
singleLine = true,
cursorBrush = SolidColor(c.accent),
textStyle = MaterialTheme.typography.bodyMedium.copy(color = if (enabled) c.text else c.muted),
visualTransformation = visualTransformation,
modifier = Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused },
)
}
trailing?.invoke()
}
}
@Preview
@Composable
private fun FieldEditorPreview() {
PreviewSurface {
Column(verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(4.dp)) {
FieldEditor("Fällig") { FieldInput("02.08.2026", {}) }
FieldEditor("Ort") { FieldInput("", {}, placeholder = "Adresse oder Ort…") }
FieldHint("Presets; der Muster-Editor folgt später.")
}
}
}
@@ -0,0 +1,107 @@
package terrata.ui.components.field
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
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.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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import terrata.ui.icons.TerrataIcons
import terrata.ui.preview.PreviewSurface
import terrata.ui.theme.TerrataTheme
/**
* The enum editor of the field-edit state: a closed list, opened by a click.
*
* Deliberately *not* the inline pill editor — this state is a form, where a labelled control that
* shows its options on demand beats an input that also happens to accept free text.
*/
@Composable
public fun SelectEditor(
value: String,
options: List<String>,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val c = TerrataTheme.colors
var open by remember { mutableStateOf(false) }
Box(modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.background(c.surface)
.border(1.dp, if (open) c.accent else c.line, MaterialTheme.shapes.small)
.clickable { open = !open }
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(value, style = MaterialTheme.typography.bodyMedium, color = c.text, modifier = Modifier.weight(1f))
Icon(TerrataIcons.ChevronDown, contentDescription = null, tint = c.muted, modifier = Modifier.size(14.dp))
}
if (open) {
Popup(
onDismissRequest = { open = false },
properties = PopupProperties(focusable = true),
) {
Column(
Modifier
.width(200.dp)
.clip(MaterialTheme.shapes.small)
.background(c.surface)
.border(1.dp, c.lineStrong, MaterialTheme.shapes.small)
.padding(6.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
options.forEach { option ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.background(if (option == value) c.surface2 else c.surface)
.clickable {
onSelect(option)
open = false
}
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
Text(option, style = MaterialTheme.typography.bodySmall, color = c.text)
}
}
}
}
}
}
}
@Preview
@Composable
private fun SelectEditorPreview() {
PreviewSurface {
Box(Modifier.width(220.dp)) {
FieldEditor("Priorität") {
SelectEditor("Hoch", listOf("Keine", "Niedrig", "Mittel", "Hoch"), {})
}
}
}
}
@@ -0,0 +1,117 @@
package terrata.ui.components.field
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.BasicTextField
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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import terrata.ui.components.chip.Chip
import terrata.ui.preview.PreviewSurface
import terrata.ui.theme.TerrataTheme
private val TagInputMinWidth = 130.dp
/**
* Chips with a remove button plus a type-to-add field — event attendees and any other multi-value
* text field. ⏎ adds, ⌫ on an empty input removes the last chip.
*/
@Composable
public fun TagsEditor(
tags: List<String>,
onTagsChange: (List<String>) -> Unit,
modifier: Modifier = Modifier,
placeholder: String? = null,
) {
val c = TerrataTheme.colors
var draft by remember { mutableStateOf("") }
fun add() {
val value = draft.trim()
if (value.isNotEmpty() && value !in tags) onTagsChange(tags + value)
draft = ""
}
FlowRow(
modifier = modifier
.fillMaxWidth()
.background(c.surface, MaterialTheme.shapes.small)
.border(1.dp, c.line, MaterialTheme.shapes.small)
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
tags.forEach { tag ->
Chip(label = tag, onRemove = { onTagsChange(tags - tag) })
}
Box(Modifier.widthIn(min = TagInputMinWidth)) {
if (draft.isEmpty() && placeholder != null) {
Text(placeholder, style = MaterialTheme.typography.bodyMedium, color = c.muted)
}
BasicTextField(
value = draft,
onValueChange = { draft = it },
singleLine = true,
cursorBrush = SolidColor(c.accent),
textStyle = MaterialTheme.typography.bodyMedium.copy(color = c.text),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) {
false
} else {
when {
event.key == Key.Enter || event.key == Key.NumPadEnter -> {
add()
true
}
event.key == Key.Backspace && draft.isEmpty() && tags.isNotEmpty() -> {
onTagsChange(tags.dropLast(1))
true
}
else -> false
}
}
},
)
}
}
}
@Preview
@Composable
private fun TagsEditorPreview() {
PreviewSurface {
var tags by remember { mutableStateOf(listOf("Anna", "Boxi")) }
Box(Modifier.width(360.dp)) {
FieldEditor("Teilnehmer") {
TagsEditor(tags, { tags = it }, placeholder = "Name hinzufügen…")
}
}
}
}
@@ -0,0 +1,60 @@
package terrata.ui.components.field
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import terrata.ui.components.edit.TimeText
private const val MAX_DIGITS = 4
private const val HOUR_DIGITS = 2
/**
* A time field that only ever holds a time: four digits at most, with the colon drawn in for you.
*
* **The colon is not part of the value.** The field's content is the bare digits, and the colon is
* painted over them by a [VisualTransformation]. That is what makes it behave like chrome rather
* than like a character: the caret can never land beside it, backspace always removes a digit, and
* typing `1130` cannot end up as `11:03` because there is nothing in the middle to type around.
*
* Formatting the text instead — the obvious first attempt — moves the caret by one the moment the
* colon appears, and every later keystroke lands one position too far left.
*
* No picker on purpose: typing four digits beats any clock widget.
*/
@Composable
public fun TimeInput(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String? = null,
) {
val digits = value.filter { it.isDigit() }.take(MAX_DIGITS)
FieldInput(
value = digits,
onValueChange = { typed -> onValueChange(TimeText.format(typed)) },
modifier = modifier,
placeholder = placeholder,
visualTransformation = TimeMask,
)
}
/**
* Draws `1130` as `11:30`. The offsets shift by one past the hour, and only once the colon is
* actually shown — with two digits or fewer there is nothing to shift.
*/
private val TimeMask = VisualTransformation { text ->
val digits = text.text
val mapping = if (digits.length > HOUR_DIGITS) {
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int = if (offset > HOUR_DIGITS) offset + 1 else offset
override fun transformedToOriginal(offset: Int): Int = if (offset > HOUR_DIGITS) offset - 1 else offset
}
} else {
OffsetMapping.Identity
}
TransformedText(AnnotatedString(TimeText.format(digits)), mapping)
}
@@ -51,9 +51,26 @@ import terrata.ui.components.control.LinkDirection
import terrata.ui.components.control.SegmentOption
import terrata.ui.components.control.SegmentedSwitch
import terrata.ui.components.control.Toggle
import terrata.ui.components.core.ContactCard
import terrata.ui.components.core.ContactLine
import terrata.ui.components.core.EventCoreBlock
import terrata.ui.components.core.EventCoreLabels
import terrata.ui.components.core.EventDate
import terrata.ui.components.core.FieldRow
import terrata.ui.components.core.TaskCoreBlock
import terrata.ui.components.core.TaskCoreLabels
import terrata.ui.components.edit.InlineEditorStrings
import terrata.ui.components.edit.InlineTitleField
import terrata.ui.components.edit.InlineValueKind
import terrata.ui.components.field.AddFieldButton
import terrata.ui.components.field.AddableField
import terrata.ui.components.field.AllDayToggle
import terrata.ui.components.field.DateTimeEditor
import terrata.ui.components.field.FieldEditor
import terrata.ui.components.field.FieldHint
import terrata.ui.components.field.FieldInput
import terrata.ui.components.field.SelectEditor
import terrata.ui.components.field.TagsEditor
import terrata.ui.components.nav.Breadcrumb
import terrata.ui.components.nav.Crumb
import terrata.ui.components.nav.RailItem
@@ -72,7 +89,7 @@ private val NavWidth = 210.dp
* German display text for the demo. Components carry no language of their own, so everything a
* user reads is supplied here — the seam that later becomes a localization lookup.
*/
private val GalleryStrings = InlineEditorStrings(
internal val GalleryStrings = InlineEditorStrings(
currentMarker = "aktuell",
noMatch = "Kein Wert passt",
monthNames = listOf(
@@ -80,10 +97,11 @@ private val GalleryStrings = InlineEditorStrings(
"Juli", "August", "September", "Oktober", "November", "Dezember",
),
weekdayNames = listOf("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"),
time = "Zeit",
)
/** Fixed so the demo calendar always opens on the same month instead of drifting with the clock. */
private val DemoToday = LocalDate(2026, 8, 19)
internal val DemoToday = LocalDate(2026, 8, 20)
/**
* The living styleguide as a runnable Compose screen — the Compose port of
@@ -188,264 +206,14 @@ private fun galleryGroups(): List<GalleryGroup> = listOf(
GallerySection("panel", "Panel (Info / Links)") { PanelSection() },
GallerySection("inline", "Inline-Attribute") { InlineAttributesSection() },
GallerySection("title", "Titel & Checkbox") { TitleSection() },
GallerySection("fields", "Field-Editoren") { FieldEditorsSection() },
GallerySection("core-task", "Core-Block — Task") { TaskCoreSection() },
GallerySection("core-contact", "Core-Block — Kontakt") { ContactCoreSection() },
GallerySection("core-event", "Core-Block — Termin") { EventCoreSection() },
),
),
)
// ---- Foundations ----
@Composable
private fun ChromeColourSection() {
val c = TerrataTheme.colors
val sp = TerrataTheme.spacing
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.m),
verticalArrangement = Arrangement.spacedBy(sp.m),
) {
Swatch("bg", c.bg)
Swatch("surface", c.surface)
Swatch("surface-2", c.surface2)
Swatch("text", c.text)
Swatch("muted", c.muted)
Swatch("line", c.line)
Swatch("chrome", c.chrome)
Swatch("chrome-text", c.chromeText)
}
}
@Composable
private fun AccentColourSection() {
val c = TerrataTheme.colors
val sp = TerrataTheme.spacing
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.m),
verticalArrangement = Arrangement.spacedBy(sp.m),
) {
Swatch("accent", c.accent)
Swatch("edge-in", c.edgeIn)
Swatch("edge-out", c.edgeOut)
}
}
@Composable
private fun TypographySection() {
val c = TerrataTheme.colors
val sp = TerrataTheme.spacing
Column(verticalArrangement = Arrangement.spacedBy(sp.s)) {
Text("Display L — 22 / 600", style = MaterialTheme.typography.titleLarge, color = c.text)
Text("Display M — 15 / 600", style = MaterialTheme.typography.titleMedium, color = c.text)
Text("Body — 14 / 400", style = MaterialTheme.typography.bodyMedium, color = c.text)
Text("Body S — 13 / 400", style = MaterialTheme.typography.bodySmall, color = c.text)
Text("LABEL / EYEBROW", style = MaterialTheme.typography.labelSmall, color = c.muted)
}
}
@Composable
private fun SpacingSection() {
val sp = TerrataTheme.spacing
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.l),
verticalArrangement = Arrangement.spacedBy(sp.s),
) {
SpacingBar("4", sp.xs)
SpacingBar("8", sp.s)
SpacingBar("12", sp.m)
SpacingBar("16", sp.l)
SpacingBar("20", sp.xl)
SpacingBar("24", sp.xxl)
SpacingBar("32", sp.xxxl)
SpacingBar("40", sp.huge)
}
}
// ---- Components ----
@Composable
private fun ButtonsSection() {
val sp = TerrataTheme.spacing
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.m),
verticalArrangement = Arrangement.spacedBy(sp.m),
) {
PrimaryButton("Neu", onClick = {})
GhostButton("Abbrechen", onClick = {})
GhostButton("Neu", onClick = {}, leadingIcon = TerrataIcons.Plus)
TerrataIconButton(TerrataIcons.Info, "Info", onClick = {})
TerrataIconButton(TerrataIcons.Link, "Links", onClick = {})
TerrataIconButton(TerrataIcons.Info, "aktiv", onClick = {}, active = true)
}
}
@Composable
private fun ControlsSection() {
val sp = TerrataTheme.spacing
var renderer by remember { mutableStateOf("Box") }
var notes by remember { mutableStateOf(true) }
var tasks by remember { mutableStateOf(true) }
var events by remember { mutableStateOf(false) }
var hideDone by remember { mutableStateOf(true) }
var incoming by remember { mutableStateOf(true) }
var outgoing by remember { mutableStateOf(true) }
Column(verticalArrangement = Arrangement.spacedBy(sp.l)) {
SegmentedSwitch(
options = listOf("Box", "Liste", "Map", "Graph").map { SegmentOption(it, it) },
selected = renderer,
onSelect = { renderer = it },
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.xl),
verticalArrangement = Arrangement.spacedBy(sp.s),
) {
FilterCheckbox("Notizen", notes, { notes = it }, icon = TerrataIcons.Note)
FilterCheckbox("Tasks", tasks, { tasks = it }, icon = TerrataIcons.Check)
FilterCheckbox("Termine", events, { events = it })
}
Toggle(hideDone, { hideDone = it }, label = "Erledigte ausblenden")
FlowRow(horizontalArrangement = Arrangement.spacedBy(sp.m)) {
DirectionToggle(LinkDirection.Incoming, "Eingehend", incoming, { incoming = it })
DirectionToggle(LinkDirection.Outgoing, "Ausgehend", outgoing, { outgoing = it })
}
}
}
@Composable
private fun ChipsSection() {
val sp = TerrataTheme.spacing
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.s),
verticalArrangement = Arrangement.spacedBy(sp.s),
) {
PropertyPill("Fällig", "19. Aug")
PropertyPill("Priorität", "Hoch", accentBadge = true)
PropertyPill("Höhe", "1322 m ü. M.")
Chip("Anna", onRemove = {})
Chip("Interlaken", leadingIcon = TerrataIcons.Pin, onRemove = {})
}
}
@Composable
private fun NavSection() {
val c = TerrataTheme.colors
val sp = TerrataTheme.spacing
var selectedTab by remember { mutableStateOf(0) }
Column(verticalArrangement = Arrangement.spacedBy(sp.l)) {
Row(
modifier = Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(c.chrome).padding(sp.s),
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
Tab("Home", selectedTab == 0, { selectedTab = 0 }, icon = TerrataIcons.Home)
Tab("Reisen", selectedTab == 1, { selectedTab = 1 }, icon = TerrataIcons.Folder, onClose = {})
Tab("Hardergrat", selectedTab == 2, { selectedTab = 2 }, icon = TerrataIcons.Note, onClose = {})
}
Box(
modifier = Modifier.width(240.dp).clip(MaterialTheme.shapes.medium).background(c.surface2)
.border(1.dp, c.line, MaterialTheme.shapes.medium).padding(sp.s),
) {
Column {
RailItem("Home", TerrataIcons.Home, {}, active = true)
RailItem("Reisen", TerrataIcons.Folder, {})
RailItem(
"Offene Tasks",
TerrataIcons.Search,
{},
trailing = { Text("12", color = c.muted, style = MaterialTheme.typography.bodySmall) },
)
}
}
Breadcrumb(listOf(Crumb("Home") {}, Crumb("Reisen") {}, Crumb("Hardergrat")))
}
}
@Composable
private fun TilesSection() {
val sp = TerrataTheme.spacing
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.l),
verticalArrangement = Arrangement.spacedBy(sp.l),
) {
Tile("Reisen", "8 Einträge", TileCover.FolderGradient(), onClick = {})
Tile("Meeting-Notiz", "gestern", TileCover.ImagePlaceholder, onClick = {})
Tile("Hardergrat", "24,3 km", TileCover.MiniMap, onClick = {})
Tile("Offene Tasks", "Query", TileCover.QueryGlyph(), onClick = {})
}
}
@Composable
private fun PanelSection() {
val sp = TerrataTheme.spacing
var role by remember { mutableStateOf("CTO") }
Column {
Box(Modifier.width(320.dp)) {
Panel("Info") {
EditablePropertyRow(
icon = TerrataIcons.Person,
propertyKey = "Funktion",
value = role,
onValueChange = { role = it },
kind = InlineValueKind.PlainText,
onReference = {},
referenceDescription = "In Notiz einfügen",
)
PropertyRow(TerrataIcons.Pin, "Ort", "Interlaken")
}
}
Spacer(Modifier.height(sp.xs))
}
}
@Composable
private fun InlineAttributesSection() {
val c = TerrataTheme.colors
val sp = TerrataTheme.spacing
var due by remember { mutableStateOf("02.08.2026") }
var priority by remember { mutableStateOf("Hoch") }
var status by remember { mutableStateOf("Offen") }
var place by remember { mutableStateOf("Interlaken") }
Column(verticalArrangement = Arrangement.spacedBy(sp.m)) {
Text(
"Die Pille ist das Eingabefeld: anklicken, der Wert ist markiert und wird überschrieben. " +
"Nur der Picker, den der Werttyp braucht, klappt darunter auf.",
style = MaterialTheme.typography.bodySmall,
color = c.muted,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.s),
verticalArrangement = Arrangement.spacedBy(sp.s),
) {
EditablePropertyPill("Fällig", due, { due = it }, InlineValueKind.Date(DemoToday), strings = GalleryStrings)
EditablePropertyPill(
label = "Priorität",
value = priority,
onValueChange = { priority = it },
kind = InlineValueKind.Options(listOf("Keine", "Niedrig", "Mittel", "Hoch")),
strings = GalleryStrings,
accentBadge = true,
)
EditablePropertyPill(
label = "Status",
value = status,
onValueChange = { status = it },
kind = InlineValueKind.Options(listOf("Offen", "In Arbeit", "Abgebrochen", "Erledigt")),
strings = GalleryStrings,
)
EditablePropertyPill("Ort", place, { place = it }, InlineValueKind.PlainText, strings = GalleryStrings)
}
}
}
@Composable
private fun TitleSection() {
val sp = TerrataTheme.spacing
var noteTitle by remember { mutableStateOf("Hardergrat") }
var taskTitle by remember { mutableStateOf("Hüttenreservation bestätigen") }
var done by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(sp.l)) {
InlineTitleField(noteTitle, { noteTitle = it })
InlineTitleField(taskTitle, { taskTitle = it }, done = done, onDoneChange = { done = it })
}
}
// ---- shared helpers ----
@Composable
@@ -467,7 +235,7 @@ private fun SectionCard(title: String, content: @Composable () -> Unit) {
}
@Composable
private fun Swatch(name: String, color: Color) {
internal fun Swatch(name: String, color: Color) {
val c = TerrataTheme.colors
Column(Modifier.width(120.dp)) {
Box(
@@ -484,7 +252,7 @@ private fun Swatch(name: String, color: Color) {
}
@Composable
private fun SpacingBar(label: String, size: Dp) {
internal fun SpacingBar(label: String, size: Dp) {
val c = TerrataTheme.colors
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.size(size).clip(RoundedCornerShape(3.dp)).background(c.accent))
@@ -0,0 +1,471 @@
package terrata.ui.gallery
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.remember
import androidx.compose.runtime.rememberCoroutineScope
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.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import terrata.ui.components.button.GhostButton
import terrata.ui.components.button.PrimaryButton
import terrata.ui.components.button.TerrataIconButton
import terrata.ui.components.chip.Chip
import terrata.ui.components.chip.EditablePropertyPill
import terrata.ui.components.chip.PropertyPill
import terrata.ui.components.control.DirectionToggle
import terrata.ui.components.control.FilterCheckbox
import terrata.ui.components.control.LinkDirection
import terrata.ui.components.control.SegmentOption
import terrata.ui.components.control.SegmentedSwitch
import terrata.ui.components.control.Toggle
import terrata.ui.components.core.ContactCard
import terrata.ui.components.core.ContactCoreLabels
import terrata.ui.components.core.ContactLine
import terrata.ui.components.core.EventCoreBlock
import terrata.ui.components.core.EventCoreLabels
import terrata.ui.components.core.EventDate
import terrata.ui.components.core.FieldRow
import terrata.ui.components.core.TaskCoreBlock
import terrata.ui.components.core.TaskCoreLabels
import terrata.ui.components.edit.InlineEditorStrings
import terrata.ui.components.edit.InlineTitleField
import terrata.ui.components.edit.InlineValueKind
import terrata.ui.components.field.AddFieldButton
import terrata.ui.components.field.AddableField
import terrata.ui.components.field.AllDayToggle
import terrata.ui.components.field.DateTimeEditor
import terrata.ui.components.field.FieldEditor
import terrata.ui.components.field.FieldHint
import terrata.ui.components.field.FieldInput
import terrata.ui.components.field.SelectEditor
import terrata.ui.components.field.TagsEditor
import terrata.ui.components.nav.Breadcrumb
import terrata.ui.components.nav.Crumb
import terrata.ui.components.nav.RailItem
import terrata.ui.components.nav.Tab
import terrata.ui.components.panel.EditablePropertyRow
import terrata.ui.components.panel.Panel
import terrata.ui.components.panel.PropertyRow
import terrata.ui.components.tile.Tile
import terrata.ui.components.tile.TileCover
import terrata.ui.icons.TerrataIcons
import terrata.ui.theme.TerrataTheme
/** The component demos of the gallery, in the order the sidenav lists them. */
// ---- Components ----
@Composable
internal fun ButtonsSection() {
val sp = TerrataTheme.spacing
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.m),
verticalArrangement = Arrangement.spacedBy(sp.m),
) {
PrimaryButton("Neu", onClick = {})
GhostButton("Abbrechen", onClick = {})
GhostButton("Neu", onClick = {}, leadingIcon = TerrataIcons.Plus)
TerrataIconButton(TerrataIcons.Info, "Info", onClick = {})
TerrataIconButton(TerrataIcons.Link, "Links", onClick = {})
TerrataIconButton(TerrataIcons.Info, "aktiv", onClick = {}, active = true)
}
}
@Composable
internal fun ControlsSection() {
val sp = TerrataTheme.spacing
var renderer by remember { mutableStateOf("Box") }
var notes by remember { mutableStateOf(true) }
var tasks by remember { mutableStateOf(true) }
var events by remember { mutableStateOf(false) }
var hideDone by remember { mutableStateOf(true) }
var incoming by remember { mutableStateOf(true) }
var outgoing by remember { mutableStateOf(true) }
Column(verticalArrangement = Arrangement.spacedBy(sp.l)) {
SegmentedSwitch(
options = listOf("Box", "Liste", "Map", "Graph").map { SegmentOption(it, it) },
selected = renderer,
onSelect = { renderer = it },
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.xl),
verticalArrangement = Arrangement.spacedBy(sp.s),
) {
FilterCheckbox("Notizen", notes, { notes = it }, icon = TerrataIcons.Note)
FilterCheckbox("Tasks", tasks, { tasks = it }, icon = TerrataIcons.Check)
FilterCheckbox("Termine", events, { events = it })
}
Toggle(hideDone, { hideDone = it }, label = "Erledigte ausblenden")
FlowRow(horizontalArrangement = Arrangement.spacedBy(sp.m)) {
DirectionToggle(LinkDirection.Incoming, "Eingehend", incoming, { incoming = it })
DirectionToggle(LinkDirection.Outgoing, "Ausgehend", outgoing, { outgoing = it })
}
}
}
@Composable
internal fun ChipsSection() {
val sp = TerrataTheme.spacing
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.s),
verticalArrangement = Arrangement.spacedBy(sp.s),
) {
PropertyPill("Fällig", "19. Aug")
PropertyPill("Priorität", "Hoch", accentBadge = true)
PropertyPill("Höhe", "1322 m ü. M.")
Chip("Anna", onRemove = {})
Chip("Interlaken", leadingIcon = TerrataIcons.Pin, onRemove = {})
}
}
@Composable
internal fun NavSection() {
val c = TerrataTheme.colors
val sp = TerrataTheme.spacing
var selectedTab by remember { mutableStateOf(0) }
Column(verticalArrangement = Arrangement.spacedBy(sp.l)) {
Row(
modifier = Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(c.chrome).padding(sp.s),
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
Tab("Home", selectedTab == 0, { selectedTab = 0 }, icon = TerrataIcons.Home)
Tab("Reisen", selectedTab == 1, { selectedTab = 1 }, icon = TerrataIcons.Folder, onClose = {})
Tab("Hardergrat", selectedTab == 2, { selectedTab = 2 }, icon = TerrataIcons.Note, onClose = {})
}
Box(
modifier = Modifier.width(240.dp).clip(MaterialTheme.shapes.medium).background(c.surface2)
.border(1.dp, c.line, MaterialTheme.shapes.medium).padding(sp.s),
) {
Column {
RailItem("Home", TerrataIcons.Home, {}, active = true)
RailItem("Reisen", TerrataIcons.Folder, {})
RailItem(
"Offene Tasks",
TerrataIcons.Search,
{},
trailing = { Text("12", color = c.muted, style = MaterialTheme.typography.bodySmall) },
)
}
}
Breadcrumb(listOf(Crumb("Home") {}, Crumb("Reisen") {}, Crumb("Hardergrat")))
}
}
@Composable
internal fun TilesSection() {
val sp = TerrataTheme.spacing
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.l),
verticalArrangement = Arrangement.spacedBy(sp.l),
) {
Tile("Reisen", "8 Einträge", TileCover.FolderGradient(), onClick = {})
Tile("Meeting-Notiz", "gestern", TileCover.ImagePlaceholder, onClick = {})
Tile("Hardergrat", "24,3 km", TileCover.MiniMap, onClick = {})
Tile("Offene Tasks", "Query", TileCover.QueryGlyph(), onClick = {})
}
}
@Composable
internal fun PanelSection() {
val sp = TerrataTheme.spacing
var role by remember { mutableStateOf("CTO") }
Column {
Box(Modifier.width(320.dp)) {
Panel("Info") {
EditablePropertyRow(
icon = TerrataIcons.Person,
propertyKey = "Funktion",
value = role,
onValueChange = { role = it },
kind = InlineValueKind.PlainText,
onReference = {},
referenceDescription = "In Notiz einfügen",
)
PropertyRow(TerrataIcons.Pin, "Ort", "Interlaken")
}
}
Spacer(Modifier.height(sp.xs))
}
}
@Composable
internal fun InlineAttributesSection() {
val c = TerrataTheme.colors
val sp = TerrataTheme.spacing
var due by remember { mutableStateOf("02.08.2026") }
var priority by remember { mutableStateOf("Hoch") }
var status by remember { mutableStateOf("Offen") }
var place by remember { mutableStateOf("Interlaken") }
Column(verticalArrangement = Arrangement.spacedBy(sp.m)) {
Text(
"Die Pille ist das Eingabefeld: anklicken, der Wert ist markiert und wird überschrieben. " +
"Nur der Picker, den der Werttyp braucht, klappt darunter auf.",
style = MaterialTheme.typography.bodySmall,
color = c.muted,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.s),
verticalArrangement = Arrangement.spacedBy(sp.s),
) {
EditablePropertyPill("Fällig", due, { due = it }, InlineValueKind.Date(DemoToday), strings = GalleryStrings)
EditablePropertyPill(
label = "Priorität",
value = priority,
onValueChange = { priority = it },
kind = InlineValueKind.Options(listOf("Keine", "Niedrig", "Mittel", "Hoch")),
strings = GalleryStrings,
accentBadge = true,
)
EditablePropertyPill(
label = "Status",
value = status,
onValueChange = { status = it },
kind = InlineValueKind.Options(listOf("Offen", "In Arbeit", "Abgebrochen", "Erledigt")),
strings = GalleryStrings,
)
EditablePropertyPill("Ort", place, { place = it }, InlineValueKind.PlainText, strings = GalleryStrings)
}
}
}
@Composable
internal fun TitleSection() {
val sp = TerrataTheme.spacing
var noteTitle by remember { mutableStateOf("Hardergrat") }
var taskTitle by remember { mutableStateOf("Hüttenreservation bestätigen") }
var done by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(sp.l)) {
InlineTitleField(noteTitle, { noteTitle = it })
InlineTitleField(taskTitle, { taskTitle = it }, done = done, onDoneChange = { done = it })
}
}
@Composable
internal fun FieldEditorsSection() {
val sp = TerrataTheme.spacing
var text by remember { mutableStateOf("Bahnhofstrasse 1") }
var select by remember { mutableStateOf("Mittel") }
var allDay by remember { mutableStateOf(false) }
var date by remember { mutableStateOf("05.08.2026") }
var time by remember { mutableStateOf("09:00") }
var tags by remember { mutableStateOf(listOf("Anna", "Boxi")) }
Column(verticalArrangement = Arrangement.spacedBy(sp.l)) {
FieldRow {
Box(Modifier.weight(1f)) { FieldEditor("Strasse") { FieldInput(text, { text = it }) } }
Box(Modifier.weight(1f)) {
FieldEditor("Priorität") {
SelectEditor(select, listOf("Keine", "Niedrig", "Mittel", "Hoch"), { select = it })
}
}
}
FieldEditor("Beginn") {
DateTimeEditor(date, { date = it }, time, { time = it }, allDay, DemoToday, strings = GalleryStrings)
}
AllDayToggle(allDay, { allDay = it }, "Ganztägig")
FieldEditor("Teilnehmer") {
TagsEditor(tags, { tags = it }, placeholder = "Name hinzufügen…")
}
FieldHint("Presets; der volle Muster-Editor folgt später.")
AddFieldButton(
label = "Feld hinzufügen",
menuTitle = "Weitere Task-Felder",
fields = listOf(
AddableField("Status", "In Arbeit / Abgebrochen"),
AddableField("Wiederholung", "Serie"),
AddableField("Erinnerung"),
),
onAdd = {},
)
}
}
@Composable
internal fun TaskCoreSection() {
val sp = TerrataTheme.spacing
var due by remember { mutableStateOf("02.08.2026") }
var priority by remember { mutableStateOf("Hoch") }
var title by remember { mutableStateOf("Hüttenreservation bestätigen") }
var done by remember { mutableStateOf(false) }
var fieldEdit by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(sp.l)) {
StateSwitch(fieldEdit) { fieldEdit = it }
InlineTitleField(title, { title = it }, done = done, onDoneChange = { done = it })
TaskCoreBlock(
due = due,
onDueChange = { due = it },
priority = priority,
onPriorityChange = { priority = it },
labels = TaskLabels,
today = DemoToday,
fieldEdit = fieldEdit,
strings = GalleryStrings,
optionalFields = TaskOptionalFields,
)
}
}
@Composable
internal fun ContactCoreSection() {
val sp = TerrataTheme.spacing
var title by remember { mutableStateOf("Anna Muster") }
var fieldEdit by remember { mutableStateOf(false) }
var lines by remember {
mutableStateOf(
listOf(
ContactLine(TerrataIcons.Mail, "E-Mail", "anna.muster@example.com"),
ContactLine(TerrataIcons.Phone, "Tel", "+41 44 123 45 67"),
ContactLine(TerrataIcons.Phone, "Mobil", "+41 79 987 65 43"),
),
)
}
Column(verticalArrangement = Arrangement.spacedBy(sp.l)) {
StateSwitch(fieldEdit) { fieldEdit = it }
InlineTitleField(title, { title = it })
ContactCard(
initials = "AM",
lines = lines,
addressLabel = "Adresse",
addressLines = listOf("Bahnhofstrasse 1", "8001 Zürich"),
fieldEdit = fieldEdit,
onLinesChange = { lines = it },
labels = ContactLabels,
optionalFields = ContactOptionalFields,
)
if (fieldEdit) {
FieldHint("Ziehen zum Sortieren und Label-Autovervollständigung folgen mit Phase 3.")
}
}
}
private val ContactLabels = ContactCoreLabels(
contactDetails = "Kontaktdaten",
addRow = "Zeile hinzufügen",
labelPlaceholder = "Bezeichnung",
valuePlaceholder = "Wert",
remove = "Zeile entfernen",
addField = "Feld hinzufügen",
addFieldMenuTitle = "Weitere Kontakt-Felder",
)
private val ContactOptionalFields = listOf(
AddableField("Organisation"),
AddableField("Funktion"),
AddableField("Geburtstag"),
)
@Composable
internal fun EventCoreSection() {
val sp = TerrataTheme.spacing
var title by remember { mutableStateOf("Review Terrata") }
var allDay by remember { mutableStateOf(false) }
var location by remember { mutableStateOf("Ergon, Zürich") }
var attendees by remember { mutableStateOf(listOf("Anna", "Boxi")) }
var startDate by remember { mutableStateOf("05.08.2026") }
var startTime by remember { mutableStateOf("09:00") }
var endDate by remember { mutableStateOf("05.08.2026") }
var endTime by remember { mutableStateOf("10:00") }
var fieldEdit by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(sp.l)) {
StateSwitch(fieldEdit) { fieldEdit = it }
InlineTitleField(title, { title = it })
EventCoreBlock(
date = EventDate("Aug", "05", "Mi"),
timeRange = "09:00 – 10:00",
allDay = allDay,
onAllDayChange = { allDay = it },
location = location,
onLocationChange = { location = it },
labels = EventLabels,
today = DemoToday,
strings = GalleryStrings,
fieldEdit = fieldEdit,
locationLines = listOf("Ergon", "Zürich"),
startDate = startDate,
onStartDateChange = { startDate = it },
startTime = startTime,
onStartTimeChange = { startTime = it },
endDate = endDate,
onEndDateChange = { endDate = it },
endTime = endTime,
onEndTimeChange = { endTime = it },
attendees = attendees,
onAttendeesChange = { attendees = it },
optionalFields = EventOptionalFields,
)
}
}
/** Flips a core block between its two states — the header pencil, standing in for the real header. */
@Composable
internal fun StateSwitch(fieldEdit: Boolean, onChange: (Boolean) -> Unit) {
SegmentedSwitch(
options = listOf(SegmentOption(false, "Normal"), SegmentOption(true, "Felder bearbeiten")),
selected = fieldEdit,
onSelect = onChange,
)
}
internal val TaskLabels = TaskCoreLabels(
due = "Fällig",
priority = "Priorität",
priorityOptions = listOf("Keine", "Niedrig", "Mittel", "Hoch"),
addField = "Feld hinzufügen",
addFieldMenuTitle = "Weitere Task-Felder",
)
internal val TaskOptionalFields = listOf(
AddableField("Status", "In Arbeit / Abgebrochen"),
AddableField("Wiederholung", "Serie"),
AddableField("Erinnerung"),
)
internal val EventLabels = EventCoreLabels(
start = "Beginn",
end = "Ende",
allDay = "Ganztägig",
location = "Ort",
attendees = "Teilnehmer",
addField = "Feld hinzufügen",
addFieldMenuTitle = "Weitere Termin-Felder",
attendeePlaceholder = "Name hinzufügen…",
)
internal val EventOptionalFields = listOf(
AddableField("Wiederholung", "Serie"),
AddableField("Status", "Bestätigt / Vorläufig / Abgesagt"),
AddableField("Erinnerung"),
)
@@ -0,0 +1,152 @@
package terrata.ui.gallery
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.remember
import androidx.compose.runtime.rememberCoroutineScope
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.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import terrata.ui.components.button.GhostButton
import terrata.ui.components.button.PrimaryButton
import terrata.ui.components.button.TerrataIconButton
import terrata.ui.components.chip.Chip
import terrata.ui.components.chip.EditablePropertyPill
import terrata.ui.components.chip.PropertyPill
import terrata.ui.components.control.DirectionToggle
import terrata.ui.components.control.FilterCheckbox
import terrata.ui.components.control.LinkDirection
import terrata.ui.components.control.SegmentOption
import terrata.ui.components.control.SegmentedSwitch
import terrata.ui.components.control.Toggle
import terrata.ui.components.core.ContactCard
import terrata.ui.components.core.ContactLine
import terrata.ui.components.core.EventCoreBlock
import terrata.ui.components.core.EventCoreLabels
import terrata.ui.components.core.EventDate
import terrata.ui.components.core.FieldRow
import terrata.ui.components.core.TaskCoreBlock
import terrata.ui.components.core.TaskCoreLabels
import terrata.ui.components.edit.InlineEditorStrings
import terrata.ui.components.edit.InlineTitleField
import terrata.ui.components.edit.InlineValueKind
import terrata.ui.components.field.AddFieldButton
import terrata.ui.components.field.AddableField
import terrata.ui.components.field.AllDayToggle
import terrata.ui.components.field.DateTimeEditor
import terrata.ui.components.field.FieldEditor
import terrata.ui.components.field.FieldHint
import terrata.ui.components.field.FieldInput
import terrata.ui.components.field.SelectEditor
import terrata.ui.components.field.TagsEditor
import terrata.ui.components.nav.Breadcrumb
import terrata.ui.components.nav.Crumb
import terrata.ui.components.nav.RailItem
import terrata.ui.components.nav.Tab
import terrata.ui.components.panel.EditablePropertyRow
import terrata.ui.components.panel.Panel
import terrata.ui.components.panel.PropertyRow
import terrata.ui.components.tile.Tile
import terrata.ui.components.tile.TileCover
import terrata.ui.icons.TerrataIcons
import terrata.ui.theme.TerrataTheme
/** The token demos of the gallery. Split off so no single file owns the whole styleguide. */
// ---- Foundations ----
@Composable
internal fun ChromeColourSection() {
val c = TerrataTheme.colors
val sp = TerrataTheme.spacing
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.m),
verticalArrangement = Arrangement.spacedBy(sp.m),
) {
Swatch("bg", c.bg)
Swatch("surface", c.surface)
Swatch("surface-2", c.surface2)
Swatch("text", c.text)
Swatch("muted", c.muted)
Swatch("line", c.line)
Swatch("chrome", c.chrome)
Swatch("chrome-text", c.chromeText)
}
}
@Composable
internal fun AccentColourSection() {
val c = TerrataTheme.colors
val sp = TerrataTheme.spacing
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.m),
verticalArrangement = Arrangement.spacedBy(sp.m),
) {
Swatch("accent", c.accent)
Swatch("edge-in", c.edgeIn)
Swatch("edge-out", c.edgeOut)
}
}
@Composable
internal fun TypographySection() {
val c = TerrataTheme.colors
val sp = TerrataTheme.spacing
Column(verticalArrangement = Arrangement.spacedBy(sp.s)) {
Text("Display L — 22 / 600", style = MaterialTheme.typography.titleLarge, color = c.text)
Text("Display M — 15 / 600", style = MaterialTheme.typography.titleMedium, color = c.text)
Text("Body — 14 / 400", style = MaterialTheme.typography.bodyMedium, color = c.text)
Text("Body S — 13 / 400", style = MaterialTheme.typography.bodySmall, color = c.text)
Text("LABEL / EYEBROW", style = MaterialTheme.typography.labelSmall, color = c.muted)
}
}
@Composable
internal fun SpacingSection() {
val sp = TerrataTheme.spacing
FlowRow(
horizontalArrangement = Arrangement.spacedBy(sp.l),
verticalArrangement = Arrangement.spacedBy(sp.s),
) {
SpacingBar("4", sp.xs)
SpacingBar("8", sp.s)
SpacingBar("12", sp.m)
SpacingBar("16", sp.l)
SpacingBar("20", sp.xl)
SpacingBar("24", sp.xxl)
SpacingBar("32", sp.xxxl)
SpacingBar("40", sp.huge)
}
}
@@ -24,8 +24,9 @@ internal fun rememberDisplayFamily(): FontFamily = FontFamily(
/**
* Type scale from docs/design/ui-ux.md, mapped onto Material3 slots:
* Display L → titleLarge, Display M → titleMedium, Body → bodyMedium, Body S → bodySmall,
* Label/eyebrow → labelSmall. Display face = Space Grotesk; body/UI = the platform sans.
* Record title → headlineSmall, Display L → titleLarge, Display M → titleMedium,
* Body → bodyMedium, Body S → bodySmall, Field label → labelMedium, Label/eyebrow → labelSmall.
* Display face = Space Grotesk; body/UI = the platform sans.
*
* Composable because the bundled fonts are loaded via `org.jetbrains.compose.resources.Font`.
*/
@@ -34,12 +35,23 @@ internal fun rememberTerrataTypography(): Typography {
val display = rememberDisplayFamily()
val body = FontFamily.Default
return Typography(
// The record's document heading. Deliberately larger than titleLarge: that one is the
// *navigation* title in the header breadcrumb, this is the H1 in the body.
headlineSmall = TextStyle(fontFamily = display, fontWeight = FontWeight.SemiBold, fontSize = 24.sp),
titleLarge = TextStyle(fontFamily = display, fontWeight = FontWeight.SemiBold, fontSize = 22.sp),
titleMedium = TextStyle(fontFamily = display, fontWeight = FontWeight.SemiBold, fontSize = 15.sp),
bodyLarge = TextStyle(fontFamily = body, fontWeight = FontWeight.Normal, fontSize = 15.sp),
bodyMedium = TextStyle(fontFamily = body, fontWeight = FontWeight.Normal, fontSize = 14.sp),
bodySmall = TextStyle(fontFamily = body, fontWeight = FontWeight.Normal, fontSize = 13.sp),
labelLarge = TextStyle(fontFamily = display, fontWeight = FontWeight.SemiBold, fontSize = 13.sp),
// The bold field label of the field-edit state — a distinct role from the muted eyebrow
// below, and rendered in `text`, not `muted`, because a dense record has to stay scannable.
labelMedium = TextStyle(
fontFamily = display,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
letterSpacing = 0.55.sp,
),
labelSmall = TextStyle(
fontFamily = display,
fontWeight = FontWeight.SemiBold,
@@ -50,6 +50,27 @@ class InlineValueLogicTest {
assertEquals("Hoch", InlineValueLogic.commitFor(InlineValueKind.Options(priorities), "e", "Hoch"))
}
@Test
fun keeps_a_time_behind_the_date_when_the_value_type_allows_one() {
val withTime = InlineValueKind.Date(LocalDate(2026, 8, 20), withTime = true)
assertEquals("14.09.2026 09:30", InlineValueLogic.commitFor(withTime, "14.9.2026 09:30", "02.08.2026"))
// An unfinished time is dropped rather than written half — the date still commits.
assertEquals("14.09.2026", InlineValueLogic.commitFor(withTime, "14.9.2026 9", "02.08.2026"))
}
@Test
fun drops_a_time_when_the_value_type_has_none() {
val dateOnly = InlineValueKind.Date(LocalDate(2026, 8, 20))
assertEquals("14.09.2026", InlineValueLogic.commitFor(dateOnly, "14.9.2026 09:30", "02.08.2026"))
}
@Test
fun reads_the_date_even_with_a_time_behind_it() {
assertEquals(LocalDate(2026, 9, 14), InlineValueLogic.parseDate("14.9.2026 09:30"))
assertEquals("09:30", InlineValueLogic.timePart("14.9.2026 09:30"))
assertEquals("", InlineValueLogic.timePart("14.9.2026"))
}
@Test
fun parses_the_three_accepted_date_forms() {
val expected = LocalDate(2026, 9, 14)
@@ -0,0 +1,72 @@
package terrata.ui.components.edit
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.v2.runComposeUiTest
import terrata.ui.components.chip.EditablePropertyPill
import terrata.ui.theme.TerrataTheme
import kotlin.test.Test
import kotlin.test.assertEquals
private val Options = listOf("Keine", "Niedrig", "Mittel", "Hoch")
/** Keyboard and mouse stay live at the same time — ↑/↓ walk the list, ⏎ commits. */
@OptIn(ExperimentalTestApi::class)
class OptionKeyboardTest {
@Test
fun arrow_down_then_enter_commits_the_highlighted_option() = runComposeUiTest {
var committed = "Hoch"
setContent {
TerrataTheme {
EditablePropertyPill(
label = "Priorität",
value = committed,
onValueChange = { committed = it },
kind = InlineValueKind.Options(Options),
modifier = Modifier.testTag("pill"),
)
}
}
onNodeWithText("Hoch").performClick()
waitForIdle()
// Highlight starts on the current value (index 3); one step down wraps to the first.
onNodeWithTag("pill").performKeyInput { pressKey(Key.DirectionDown) }
onNodeWithTag("pill").performKeyInput { pressKey(Key.Enter) }
waitForIdle()
assertEquals("Keine", committed)
}
@Test
fun arrow_up_walks_backwards() = runComposeUiTest {
var committed = "Hoch"
setContent {
TerrataTheme {
EditablePropertyPill(
label = "Priorität",
value = committed,
onValueChange = { committed = it },
kind = InlineValueKind.Options(Options),
modifier = Modifier.testTag("pill"),
)
}
}
onNodeWithText("Hoch").performClick()
waitForIdle()
onNodeWithTag("pill").performKeyInput { pressKey(Key.DirectionUp) }
onNodeWithTag("pill").performKeyInput { pressKey(Key.Enter) }
waitForIdle()
assertEquals("Mittel", committed)
}
}
@@ -0,0 +1,52 @@
package terrata.ui.components.edit
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class TimeTextTest {
@Test
fun puts_the_colon_in_while_typing_four_digits() {
assertEquals("1", TimeText.format("1"))
assertEquals("11", TimeText.format("11"))
assertEquals("11:3", TimeText.format("113"))
assertEquals("11:30", TimeText.format("1130"))
}
@Test
fun accepts_a_time_that_is_already_written_out() {
assertEquals("09:00", TimeText.format("09:00"))
}
@Test
fun never_takes_more_than_four_digits() {
assertEquals("11:30", TimeText.format("113045"))
assertEquals("11:30", TimeText.format("11:30:45"))
}
@Test
fun ignores_anything_that_is_not_a_digit() {
assertEquals("11:30", TimeText.format("11h30m"))
assertEquals("", TimeText.format("abc"))
}
@Test
fun stays_reversible_under_backspace() {
// Deleting a character must always reduce the digits — otherwise the caret gets stuck in
// front of a colon the formatter keeps re-inserting.
var text = TimeText.format("1130")
repeat(4) { text = TimeText.format(text.dropLast(1)) }
assertEquals("", text)
}
@Test
fun recognises_a_complete_time() {
assertTrue(TimeText.isComplete("09:00"))
assertTrue(TimeText.isComplete("23:59"))
assertFalse(TimeText.isComplete("11:3"), "not finished yet")
assertFalse(TimeText.isComplete("24:00"), "no such hour")
assertFalse(TimeText.isComplete("11:60"), "no such minute")
}
}
@@ -0,0 +1,89 @@
package terrata.ui.components.field
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
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.input.key.Key
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.v2.runComposeUiTest
import androidx.compose.ui.unit.dp
import terrata.ui.theme.TerrataTheme
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* The colon is chrome, not content: the field must behave as if it were not a character the user
* can put a caret in front of, delete, or type around.
*/
@OptIn(ExperimentalTestApi::class)
class TimeInputTest {
@Test
fun typing_four_digits_in_a_row_yields_that_time() = runComposeUiTest {
var time = ""
setContent {
TerrataTheme {
var state by remember { mutableStateOf("") }
Box(Modifier.width(160.dp)) {
TimeInput(
value = state,
onValueChange = {
state = it
time = it
},
)
}
}
}
onNode(hasSetTextAction()).performClick()
listOf("1", "1", "3", "0").forEach {
onNode(hasSetTextAction()).performTextInput(it)
waitForIdle()
}
assertEquals("11:30", time)
}
@Test
fun backspace_removes_a_digit_and_never_the_colon() = runComposeUiTest {
var time = ""
setContent {
TerrataTheme {
var state by remember { mutableStateOf("") }
Box(Modifier.width(160.dp)) {
TimeInput(
value = state,
onValueChange = {
state = it
time = it
},
)
}
}
}
onNode(hasSetTextAction()).performClick()
onNode(hasSetTextAction()).performTextInput("1130")
waitForIdle()
assertEquals("11:30", time)
onNode(hasSetTextAction()).performKeyInput { pressKey(Key.Backspace) }
waitForIdle()
assertEquals("11:3", time, "one keystroke back must drop one digit")
// ...and again: the caret must never get stuck in front of the colon.
onNode(hasSetTextAction()).performKeyInput { pressKey(Key.Backspace) }
waitForIdle()
assertEquals("11", time)
}
}
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.