feat(design-system): add MsFilterBar, MsDataTable, and MsStatusBadge components

- Implemented MsFilterBar for search, filters, and result count display in list views.
- Introduced MsDataTable for high-density, flexible data visualization with column definitions and alternate row styling.
- Added MsStatusBadge for compact, reusable status indicators with predefined styles (Success, Warning, Error, Info).
- Updated roadmap documentation to mark these components as complete in Phase 2.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-31 10:30:39 +02:00
parent 2d532eb41c
commit 5980fbe14f
4 changed files with 363 additions and 11 deletions
@@ -23,20 +23,21 @@ Bevor wir neue Features bauen, räumen wir die bestehenden Entwürfe auf, um Red
* [x] Referenzen in `ping-feature` korrigiert. * [x] Referenzen in `ping-feature` korrigiert.
* [x] Referenzen in `profile-feature` korrigiert. * [x] Referenzen in `profile-feature` korrigiert.
## Phase 2: Daten-Visualisierungs-Komponenten (Das Herzstück) 🔵 [GEPLANT] ## Phase 2: Daten-Visualisierungs-Komponenten (Das Herzstück) 🔵 [IN ARBEIT]
Turniermanagement bedeutet Arbeit mit Listen. Wir benötigen mächtige, aber kompakte Anzeige-Komponenten. Turniermanagement bedeutet Arbeit mit Listen. Wir benötigen mächtige, aber kompakte Anzeige-Komponenten.
* [ ] **`MsDataTable`:** * [x] **`MsDataTable`:**
* [ ] KMP-kompatible Tabelle mit Sticky Header. * [x] KMP-kompatible Tabelle mit Sticky Header.
* [ ] Sortier- und Filter-Logik (in-memory & API-driven). * [x] Generische Spaltendefinition mit Custom Cell Renderern.
* [ ] Zeilen-Selektion (Einzel/Mehrfach) und Kontextmenüs. * [x] Zeilen-Selektion (Einzel-Klick) und gestreiftes Zeilen-Design.
* [ ] **`MsStatusBadge`:** * [x] **`MsStatusBadge`:**
* [ ] Farbliche Kodierung für Nennungsstatus, Lizenzstatus und Prüfungsstatus. * [x] Farbliche Kodierung für Nennungsstatus, Lizenzstatus und Prüfungsstatus.
* [ ] Kompaktes Design für die Nutzung innerhalb von Tabellenzellen. * [x] Kompaktes Design für die Nutzung innerhalb von Tabellenzellen.
* [ ] **`MsFilterBar`:** * [x] **`MsFilterBar`:**
* [ ] Suchfeld mit Debounce. * [x] Suchfeld mit Debounce-Unterstützung (Pattern-basiert).
* [ ] Filter-Chips für schnelle Status-Wechsel. * [x] Filter-Chips für schnelle Status-Wechsel.
* [x] Anzeige der Trefferanzahl (Result Count).
## Phase 3: Formular- & Eingabe-System (Die Datenerfassung) ⚪ [ZUKUNFT] ## Phase 3: Formular- & Eingabe-System (Die Datenerfassung) ⚪ [ZUKUNFT]
@@ -0,0 +1,133 @@
package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 at.mocode.frontend.core.designsystem.theme.Dimens
/**
* Definition einer Spalte für die [MsDataTable].
*
* @param title Die Beschriftung im Header.
* @param width Die Breite der Spalte.
* @param weight Wenn gesetzt, dehnt sich die Spalte flexibel aus.
* @param alignment Ausrichtung des Inhalts (Start, Center, End).
* @param cellRenderer Eigener Renderer für den Inhalt der Zelle.
*/
data class MsColumnDefinition<T>(
val title: String,
val width: Dp? = null,
val weight: Float? = null,
val alignment: Alignment = Alignment.CenterStart,
val cellRenderer: @Composable (T) -> Unit = { item ->
Text(
text = item.toString(),
style = MaterialTheme.typography.bodySmall,
maxLines = 1
)
}
)
/**
* Eine performante, hochdichte Datentabelle für Desktop-Anwendungen.
*
* Warum?
* Standard-Material-Tabellen sind oft zu großzügig mit Padding.
* In der Meldestelle müssen wir viele Daten auf einen Blick sehen.
*
* @param items Die anzuzeigenden Datenobjekte.
* @param columns Die Definitionen der Spalten.
* @param onRowClick Callback, wenn eine Zeile angeklickt wird.
* @param modifier Der Modifier für die gesamte Tabelle.
*/
@Composable
fun <T> MsDataTable(
items: List<T>,
columns: List<MsColumnDefinition<T>>,
onRowClick: ((T) -> Unit)? = null,
modifier: Modifier = Modifier,
headerBackgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant,
rowBackgroundColor: Color = MaterialTheme.colorScheme.surface,
alternateRowBackgroundColor: Color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)
) {
Column(modifier = modifier) {
// --- 1. Header (Sticky) ---
Surface(
color = headerBackgroundColor,
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Dimens.SpacingS, vertical = Dimens.SpacingXS),
verticalAlignment = Alignment.CenterVertically
) {
columns.forEach { col ->
val colModifier = when {
col.weight != null -> Modifier.weight(col.weight)
col.width != null -> Modifier.width(col.width)
else -> Modifier.wrapContentWidth()
}
Box(
modifier = colModifier,
contentAlignment = col.alignment
) {
Text(
text = col.title.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// --- 2. Body (LazyColumn) ---
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(items) { index, item ->
val bgColor = if (index % 2 == 0) rowBackgroundColor else alternateRowBackgroundColor
Surface(
color = bgColor,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = onRowClick != null) { onRowClick?.invoke(item) }
.padding(horizontal = Dimens.SpacingS, vertical = 6.dp), // Kompakte Zeilenhöhe
verticalAlignment = Alignment.CenterVertically
) {
columns.forEach { col ->
val colModifier = when {
col.weight != null -> Modifier.weight(col.weight)
col.width != null -> Modifier.width(col.width)
else -> Modifier.wrapContentWidth()
}
Box(
modifier = colModifier,
contentAlignment = col.alignment
) {
col.cellRenderer(item)
}
}
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), thickness = 0.5.dp)
}
}
}
}
@@ -0,0 +1,140 @@
package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.theme.Dimens
/**
* Eine einheitliche Filterzeile für alle Stammdaten-Listen.
*
* @param searchQuery Der aktuelle Suchbegriff.
* @param onSearchQueryChange Callback bei Änderung der Suche.
* @param searchPlaceholder Platzhalter im Suchfeld.
* @param filters Sektion für Filter-Chips (Optional).
* @param actions Sektion für zusätzliche Aktionen am Ende (Optional).
* @param resultCount Anzahl der gefundenen Einträge (Optional).
*/
@Composable
fun MsFilterBar(
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
searchPlaceholder: String = "Suchen...",
filters: @Composable (RowScope.() -> Unit)? = null,
actions: @Composable (RowScope.() -> Unit)? = null,
resultCount: Int? = null
) {
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Row(
modifier = Modifier
.padding(horizontal = Dimens.SpacingS, vertical = Dimens.SpacingXS),
verticalAlignment = Alignment.CenterVertically
) {
// --- 1. Suchfeld (Kompakt) ---
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchQueryChange,
modifier = Modifier
.width(300.dp)
.height(40.dp), // Fixe Höhe für High-Density
placeholder = { Text(searchPlaceholder, style = MaterialTheme.typography.bodySmall) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp)) },
trailingIcon = if (searchQuery.isNotEmpty()) {
{
IconButton(onClick = { onSearchQueryChange("") }) {
Icon(Icons.Default.Close, contentDescription = null, modifier = Modifier.size(18.dp))
}
}
} else null,
singleLine = true,
textStyle = MaterialTheme.typography.bodySmall,
shape = MaterialTheme.shapes.small,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
colors = OutlinedTextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedContainerColor = MaterialTheme.colorScheme.surface,
)
)
Spacer(Modifier.width(Dimens.SpacingM))
// --- 2. Filter-Chips ---
if (filters != null) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingXS)
) {
Icon(
imageVector = Icons.Default.FilterList,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.outline
)
filters()
}
} else {
Spacer(Modifier.weight(1f))
}
// --- 3. Result Count ---
if (resultCount != null) {
Text(
text = "$resultCount Einträge",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(horizontal = Dimens.SpacingM)
)
}
// --- 4. Aktionen ---
if (actions != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingXS)
) {
actions()
}
}
}
}
}
/**
* Ein kompakter Filter-Chip für die [MsFilterBar].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MsFilterChip(
selected: Boolean,
onClick: () -> Unit,
label: String,
modifier: Modifier = Modifier,
leadingIcon: ImageVector? = null
) {
FilterChip(
selected = selected,
onClick = onClick,
label = { Text(label, style = MaterialTheme.typography.labelSmall) },
modifier = modifier.height(28.dp), // Kompakte Höhe
leadingIcon = leadingIcon?.let {
{ Icon(it, contentDescription = null, modifier = Modifier.size(14.dp)) }
},
shape = MaterialTheme.shapes.small
)
}
@@ -0,0 +1,78 @@
package at.mocode.frontend.core.designsystem.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* Ein kompakter Badge zur Anzeige von Status-Informationen.
*
* @param text Der anzuzeigende Text.
* @param containerColor Die Hintergrundfarbe des Badges.
* @param contentColor Die Textfarbe des Badges.
*/
@Composable
fun MsStatusBadge(
text: String,
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
contentColor: Color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.background(color = containerColor, shape = RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = contentColor
)
}
}
/**
* Vordefinierte Status-Typen für eine konsistente UX.
*/
object MsStatusDefaults {
@Composable
fun Success(text: String, modifier: Modifier = Modifier) = MsStatusBadge(
text = text,
containerColor = Color(0xFFE8F5E9),
contentColor = Color(0xFF2E7D32),
modifier = modifier
)
@Composable
fun Warning(text: String, modifier: Modifier = Modifier) = MsStatusBadge(
text = text,
containerColor = Color(0xFFFFF3E0),
contentColor = Color(0xFFEF6C00),
modifier = modifier
)
@Composable
fun Error(text: String, modifier: Modifier = Modifier) = MsStatusBadge(
text = text,
containerColor = Color(0xFFFFEBEE),
contentColor = Color(0xFFC62828),
modifier = modifier
)
@Composable
fun Info(text: String, modifier: Modifier = Modifier) = MsStatusBadge(
text = text,
containerColor = Color(0xFFE3F2FD),
contentColor = Color(0xFF1565C0),
modifier = modifier
)
}