init the FixBlock Part
This commit is contained in:
1 parent
0da48ce865
commit
6d241ea58d
29 files changed
+2673
-359
No files matched your search
+49
-6
@@ -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)
|
||||
}
|
||||
}
|
||||