package community.yuvomi.mobile import androidx.activity.compose.BackHandler import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Locale import org.json.JSONObject private val Violet = Color(0xFF6C3AED) private val PageShape = RoundedCornerShape(16.dp) @Composable fun NativeApp(controller: NativeController, needsLocalNetwork: Boolean = false, requestLocalNetwork: () -> Unit = {}) { val dark = isSystemInDarkTheme() val palette = if (dark) { darkColorScheme( primary = Color(0xFFA78BFA), background = Color(0xFF191816), surface = Color(0xFF2B2825), onSurface = Color(0xFFF5F3ED), onSurfaceVariant = Color(0xFFB4AEA5), outline = Color(0xFF948E85), onBackground = Color(0xFFF5F3ED), ) } else { lightColorScheme( primary = Violet, background = Color(0xFFF5F3ED), surface = Color.White, onSurface = Color(0xFF1D1B17), onSurfaceVariant = Color(0xFF63615B), outline = Color(0xFF8C8880), onBackground = Color(0xFF1D1B17), ) } MaterialTheme(colorScheme = palette) { val state = controller.state var confirmForget by remember { mutableStateOf(false) } var editor by remember { mutableStateOf(null) } var editing by remember { mutableStateOf(false) } var submitted by remember { mutableStateOf(false) } var remove by remember { mutableStateOf(null) } var info by remember { mutableStateOf(null) } LaunchedEffect(state.user?.text("id"), state.server) { editing = false editor = null submitted = false } LaunchedEffect(state.busy, state.error) { if (submitted && !state.busy) { submitted = false if (state.error == null) editing = false } } BackHandler(state.user != null && (state.page != NativePage.HOME || state.listId != null)) { if (!state.busy) controller.navigate(if (state.listId != null) NativePage.SHOPPING else NativePage.HOME) } Surface(Modifier.fillMaxSize(), color = palette.background) { if (state.server.isEmpty() || state.user == null) { AuthenticationScreen( state, needsLocalNetwork, requestLocalNetwork, controller::connect, controller::login, controller::verify, controller::cancelTwoFactor, { confirmForget = true }, { info = "SSO wird für den nativen API-Client noch nicht unterstützt. " + "Es wird keine Browser-Sitzung als native Anmeldung vorgetäuscht." }, ) } else { Scaffold( containerColor = palette.background, bottomBar = { Surface( Modifier.padding(horizontal = 12.dp, vertical = 8.dp).fillMaxWidth(), shape = RoundedCornerShape(32.dp), shadowElevation = 8.dp, ) { Row(Modifier.padding(6.dp), horizontalArrangement = Arrangement.SpaceEvenly) { NativePage.entries.filter { it == NativePage.HOME || it == NativePage.MORE || state.access(it.name.lowercase()) != "none" }.forEach { page -> Column( Modifier.weight(1f).clip(CircleShape).clickable(enabled = !state.busy) { controller.navigate(page) }.padding(vertical = 8.dp).testTag("nav-${page.name}"), horizontalAlignment = Alignment.CenterHorizontally, ) { Glyph(page, if (state.page == page) palette.primary else palette.onSurfaceVariant) Text( page.title, fontSize = 11.sp, color = if (state.page == page ) { palette.primary } else { palette.onSurfaceVariant }, ) } } } } }, ) { padding -> Column(Modifier.padding(padding).fillMaxSize()) { Row( Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( if (state.listId == null ) { state.page.title } else { state.listName }, fontSize = 34.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f), ) TextButton(onClick = controller::refresh, enabled = !state.busy) { Text("Aktualisieren") } } if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) state.error?.let { ErrorCard(it, controller::dismissError) } when (state.page) { NativePage.HOME -> DashboardScreen(state, controller::navigate) NativePage.MORE -> MoreScreen(state, { controller.logout() }, { confirmForget = true }) else -> { val module = state.page.name.lowercase() val write = state.access(module) == "write" && !state.busy Row(Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) { if (state.listId != null ) { TextButton(onClick = { controller.navigate(NativePage.SHOPPING) }, enabled = !state.busy) { Text("Alle Listen") } } Spacer(Modifier.weight(1f)) if (write) { Button(colors = nativeButtonColors(), onClick = { editor = null editing = true }) { Text( if (state.page == NativePage.SHOPPING && state.listId == null ) { "Neue Liste" } else { "Hinzufügen" }, ) } } } NativeCollection( state, write, onEdit = { editor = it editing = true }, onTask = controller::completeTask, onPin = controller::pinNote, onItem = controller::checkItem, onList = { controller.openList(it.getLong("id"), it.text("name")) }, onRemove = { remove = it }, ) } } } } } if (editing) { EntryEditor(state, editor, onDismiss = { editing = false }) { title, detail, priority, date -> when (state.page) { NativePage.TASKS -> controller.saveTask(editor?.getLong("id"), title, detail, priority, date) NativePage.NOTES -> controller.saveNote(editor?.getLong("id"), title, detail) NativePage.SHOPPING -> controller.saveShopping(editor?.getLong("id"), title, detail) else -> Unit } submitted = true } } if (confirmForget) { AlertDialog( onDismissRequest = { confirmForget = false }, title = { Text("Server entfernen?") }, text = { Text( "Die lokale native Sitzung und geladene Inhalte werden gelöscht. " + "Eine entfernte Sitzung wird dadurch nicht widerrufen; melde dich möglichst zuerst ab.", ) }, confirmButton = { TextButton(onClick = { confirmForget = false controller.forgetLocalServer() }, enabled = !state.busy) { Text("Lokal entfernen") } }, dismissButton = { TextButton(onClick = { confirmForget = false }) { Text("Abbrechen") } }, ) } remove?.let { path -> AlertDialog( onDismissRequest = { remove = null }, title = { Text("Eintrag löschen?") }, text = { Text("Der Eintrag wird auf deinem Server gelöscht. Diese Aktion kann nicht zurückgenommen werden.") }, confirmButton = { TextButton(onClick = { remove = null controller.remove(path) }) { Text("Löschen") } }, dismissButton = { TextButton(onClick = { remove = null }) { Text("Abbrechen") } }, ) } info?.let { message -> AlertDialog(onDismissRequest = { info = null }, title = { Text("Native Anmeldung") }, text = { Text(message) }, confirmButton = { TextButton(onClick = { info = null }) { Text("Verstanden") } }) } } } } @Composable private fun AuthenticationScreen( state: NativeState, needsNetwork: Boolean, requestNetwork: () -> Unit, connect: (String) -> Unit, login: (String, String, String) -> Unit, verify: (String) -> Unit, cancelTwoFactor: () -> Unit, forget: () -> Unit, sso: () -> Unit, ) { val keyboard = LocalSoftwareKeyboardController.current var address by remember { mutableStateOf("") } var username by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var name by remember { mutableStateOf("") } var code by remember { mutableStateOf("") } var invalidAddress by remember { mutableStateOf(false) } Column( Modifier.fillMaxSize().imePadding().verticalScroll(rememberScrollState()).padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Surface(shape = RoundedCornerShape(26.dp), modifier = Modifier.widthIn(max = 412.dp).fillMaxWidth()) { Column( Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { BrandMark() Text(state.appName, fontSize = 34.sp, fontWeight = FontWeight.Bold) Text( if (state.server.isEmpty()) { "Dein Zuhause. Dein Server." } else if (state.twoFactor) { "Bestätige deine Anmeldung" } else { "Alles für euren Alltag an einem Ort." }, color = MaterialTheme.colorScheme.onSurfaceVariant, ) if (state.server.isEmpty()) { NativeField(address, { address = it invalidAddress = false }, "Server-Adresse", Modifier.testTag("server-url"), keyboard = KeyboardType.Uri) if (invalidAddress) { Text( "Bitte eine gültige HTTPS-Adresse ohne Unterpfad eingeben.", color = MaterialTheme.colorScheme.error, ) } Text( "HTTPS · eigener Server · keine zentrale Cloud", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) if (needsNetwork) TextButton(onClick = requestNetwork) { Text("Zugriff auf private Netzwerke erlauben") } Button( colors = nativeButtonColors(), modifier = Modifier.fillMaxWidth().height(48.dp).testTag("connect"), enabled = !state.busy && address.isNotBlank(), onClick = { keyboard?.hide() if (runCatching { ServerAddress.parse(address) }.isSuccess) connect(address) else invalidAddress = true }, ) { Text("Verbinden") } } else if (state.twoFactor) { NativeField(code, { code = it }, "Code oder Wiederherstellungscode", Modifier.testTag("two-factor-code")) Button(colors = nativeButtonColors(), onClick = { keyboard?.hide() verify(code) code = "" }, enabled = !state.busy && code.isNotBlank(), modifier = Modifier.fillMaxWidth()) { Text("Bestätigen") } TextButton(onClick = cancelTwoFactor, enabled = !state.busy) { Text("Zurück zur Anmeldung") } } else { if (state.setup) NativeField(name, { name = it }, "Anzeigename") if (state.passwordAllowed || state.setup) { NativeField(username, { username = it }, "Benutzername", Modifier.testTag("username")) NativeField(password, { password = it }, "Passwort", Modifier.testTag("password"), password = true) Button( colors = nativeButtonColors(), onClick = { keyboard?.hide() val submitted = password password = "" login(username, submitted, name) }, enabled = !state.busy && username.isNotBlank() && password.isNotEmpty(), modifier = Modifier.fillMaxWidth().height(48.dp).testTag("login"), ) { Text(if (state.setup) "Haushalt einrichten" else "Anmelden") } } if (state.oidc) TextButton(onClick = sso) { Text("SSO – noch in Umsetzung") } TextButton(onClick = forget, enabled = !state.busy) { Text("Anderen Server verwenden") } } if (state.busy) CircularProgressIndicator(Modifier.size(24.dp)) state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("auth-error")) } } } } } @Composable private fun DashboardScreen(state: NativeState, navigate: (NativePage) -> Unit) { val data = state.dashboard LazyColumn( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.testTag("native-dashboard"), ) { item { Text( "Hallo, ${state.user?.text("display_name").orEmpty().ifBlank { state.user?.text("username").orEmpty() }}", fontSize = 24.sp, fontWeight = FontWeight.SemiBold, ) Text( LocalDate.now().format(DateTimeFormatter.ofPattern("EEEE, d. MMMM", Locale.GERMAN)), color = MaterialTheme.colorScheme.onSurfaceVariant, ) } if (state.access("tasks") != "none" && state.widget("tasks")) { item { DashboardCard("Aufgaben", "${data.countText("openTaskCount")} offen", NativePage.TASKS, { navigate(NativePage.TASKS) }) { val tasks = data.rows("urgentTasks") if (tasks.isEmpty()) EmptyHint("Keine dringenden Aufgaben") tasks.forEach { PreviewRow(it.text("title"), it.text("due_date")) } } } } if (state.access("calendar") != "none" && state.widget("calendar")) { item { DashboardCard("Kalender", "Nächste Termine", null, {}) { val events = data.rows("upcomingEvents") if (events.isEmpty()) EmptyHint("Keine anstehenden Termine") events.forEach { PreviewRow(it.text("title"), it.text("start_datetime").replace('T', ' ').take(16)) } } } } if (state.access("shopping") != "none" && state.widget("shopping")) { item { DashboardCard("Einkauf", "${data.countText("shoppingOpenCount")} offene Artikel", NativePage.SHOPPING, { navigate(NativePage.SHOPPING) }) { data.rows("shoppingLists").forEach { PreviewRow(it.text("name"), "${it.optInt("open_count")} offen") } if (data.rows("shoppingLists").isEmpty()) EmptyHint("Alles eingekauft") } } } if (state.access("notes") != "none" && state.widget("notes")) { item { DashboardCard("Notizen", "Angeheftet", NativePage.NOTES, { navigate(NativePage.NOTES) }) { val notes = data.rows("pinnedNotes") if (notes.isEmpty()) EmptyHint("Noch keine Notizen") notes.forEach { PreviewRow(it.text("title").ifBlank { "Notiz" }, it.text("content").take(140)) } } } } } } @Composable private fun DashboardCard( title: String, subtitle: String, page: NativePage?, onClick: () -> Unit, content: @Composable ColumnScope.() -> Unit, ) { Surface(shape = PageShape, modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { if (page != null) { Glyph(page, MaterialTheme.colorScheme.primary) Spacer(Modifier.width(8.dp)) } Column(Modifier.weight(1f)) { Text(title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) Text(subtitle, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } if (page != null) TextButton(onClick = onClick) { Text("Alle") } } content() } } } @Composable private fun NativeCollection( state: NativeState, write: Boolean, onEdit: (JSONObject) -> Unit, onTask: (JSONObject) -> Unit, onPin: (JSONObject) -> Unit, onItem: (JSONObject) -> Unit, onList: (JSONObject) -> Unit, onRemove: (String) -> Unit, ) { var query by remember(state.page, state.listId) { mutableStateOf("") } var status by remember { mutableStateOf("open") } Column(Modifier.fillMaxSize()) { NativeField(query, { query = it }, "Suchen", Modifier.padding(16.dp)) if (state.page == NativePage.TASKS) { Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { listOf("open" to "Offen", "done" to "Erledigt", "all" to "Alle").forEach { (value, label) -> FilterChip( selected = status == value, onClick = { status = value }, label = { Text(label) }, ) } } } val rows = state.rows.filter { row -> val title = if (state.page == NativePage.SHOPPING) row.text("name") else row.text("title") val matches = query.isBlank() || (title + row.text("content") + row.text("description")).contains(query, true) matches && ( state.page != NativePage.TASKS || status == "all" || if (status == "done") row.text("status") == "done" else row.text("status") != "done" ) } LazyColumn( contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { if (rows.isEmpty() && !state.busy ) { item { EmptyHint(if (query.isBlank()) "Hier gibt es noch keine Einträge." else "Keine passenden Einträge.") } } items(rows, key = { it.getLong("id") }) { row -> Surface(shape = PageShape, modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { val checked = if (state.page == NativePage.TASKS ) { row.text("status") == "done" } else { row.optInt("is_checked") == 1 } if (state.page == NativePage.TASKS || (state.page == NativePage.SHOPPING && state.listId != null) ) { Checkbox(checked, onCheckedChange = { if (state.page == NativePage.TASKS ) { onTask(row) } else { onItem(row) } }, enabled = write) } val title = if (state.page == NativePage.SHOPPING) row.text("name") else row.text("title").ifBlank { "Notiz" } Column( Modifier.weight(1f).clip(RoundedCornerShape(8.dp)).clickable( enabled = !state.busy && (write || state.page == NativePage.SHOPPING && state.listId == null), ) { if (state.page == NativePage.SHOPPING && state.listId == null) onList(row) else onEdit(row) }.padding(4.dp), ) { Text( title, fontWeight = FontWeight.SemiBold, textDecoration = if (checked) TextDecoration.LineThrough else null, ) val detail = when (state.page) { NativePage.TASKS -> listOf( row.text("category"), row.text("due_date"), row.text("priority").takeUnless { it == "none" }.orEmpty(), ).filter { it.isNotBlank() }.joinToString(" · ") NativePage.NOTES -> row.text("content") NativePage.SHOPPING -> if (state.listId != null ) { listOf(row.text("quantity"), row.text("category")).filter { it.isNotBlank() }.joinToString(" · ") } else { "${row.optInt("item_total")} Artikel" } else -> "" } if (detail.isNotBlank()) Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 14.sp) } if (state.page == NativePage.NOTES && write ) { TextButton(onClick = { onPin(row) }) { Text(if (row.optInt("pinned") == 1) "Lösen" else "Anheften") } } } if (write) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { TextButton(onClick = { onRemove( when (state.page) { NativePage.TASKS -> "/tasks/${row.getLong("id")}" NativePage.NOTES -> "/notes/${row.getLong("id")}" else -> if (state.listId == null ) { "/shopping/${row.getLong("id")}" } else { "/shopping/items/${row.getLong("id")}" } }, ) }) { Text("Löschen", color = MaterialTheme.colorScheme.error) } } } } } } } } } @Composable private fun EntryEditor(state: NativeState, entry: JSONObject?, onDismiss: () -> Unit, save: (String, String, String, String) -> Unit) { val keyboard = LocalSoftwareKeyboardController.current var title by remember { mutableStateOf(entry?.text(if (state.page == NativePage.SHOPPING) "name" else "title").orEmpty()) } var detail by remember { mutableStateOf( entry?.text( when (state.page) { NativePage.NOTES -> "content" NativePage.SHOPPING -> "quantity" else -> "description" }, ).orEmpty(), ) } var priority by remember { mutableStateOf(entry?.text("priority")?.ifBlank { "none" } ?: "none") } var date by remember { mutableStateOf(entry?.text("due_date").orEmpty()) } val validDate = date.isBlank() || runCatching { LocalDate.parse(date) }.isSuccess AlertDialog(onDismissRequest = { if (!state.busy) onDismiss() }, title = { Text( if (entry == null ) { "Hinzufügen" } else { "Bearbeiten" }, ) }, text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } NativeField(title, { title = it }, if (state.page == NativePage.SHOPPING) "Name" else "Titel", Modifier.testTag("entry-title")) if (state.page != NativePage.SHOPPING || state.listId != null ) { NativeField( detail, { detail = it }, when (state.page) { NativePage.NOTES -> "Inhalt" NativePage.SHOPPING -> "Menge" else -> "Beschreibung" }, Modifier.testTag("entry-detail"), singleLine = state.page == NativePage.SHOPPING, ) } if (state.page == NativePage.TASKS) { Text("Priorität") Column { listOf( "none" to "Keine", "low" to "Niedrig", "medium" to "Mittel", "high" to "Hoch", "urgent" to "Dringend", ).forEach { (value, label) -> FilterChip(selected = priority == value, onClick = { priority = value }, label = { Text(label) }) } } NativeField(date, { date = it }, "Fällig am (JJJJ-MM-TT)") if (!validDate) Text("Bitte ein gültiges Datum eingeben.", color = MaterialTheme.colorScheme.error) } } }, confirmButton = { TextButton( onClick = { keyboard?.hide() save(title, detail, priority, date) }, enabled = validDate && !state.busy && (title.isNotBlank() || state.page == NativePage.NOTES && detail.isNotBlank()) && (state.page != NativePage.NOTES || detail.isNotBlank()), modifier = Modifier.testTag("entry-save"), ) { Text("Speichern") } }, dismissButton = { TextButton(onClick = onDismiss, enabled = !state.busy) { Text("Abbrechen") } }) } @Composable private fun MoreScreen(state: NativeState, logout: () -> Unit, forget: () -> Unit) { LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { item { DashboardCard("Dein Haushalt", state.appName, null, {}) { Text(state.user?.text("display_name").orEmpty()) Text(state.server, color = MaterialTheme.colorScheme.onSurfaceVariant) } } item { DashboardCard("Konto", "Native Sitzung", null, {}) { TextButton(onClick = logout, enabled = !state.busy) { Text("Abmelden") } TextButton(onClick = forget, enabled = !state.busy) { Text("Server und lokale Sitzung entfernen") } } } item { DashboardCard("Nativer Neubau", "Noch keine vollständige PWA-Parität", null, {}) { Text( "Kalender, Schichtplan, Geburtstage, Küche, Vorrat, Budget, Kontakte, Dokumente, Inventar, " + "Haushaltshilfe, Abfall, Belohnungen, Gesundheit und Einstellungen werden separat nativ nachgebaut. " + "Es gibt keinen Browser-Fallback.", ) } } } } @Composable private fun nativeButtonColors() = ButtonDefaults.buttonColors( containerColor = if (isSystemInDarkTheme()) Color(0xFF7C3AED) else Violet, contentColor = Color.White, ) @Composable private fun NativeField( value: String, change: (String) -> Unit, label: String, modifier: Modifier = Modifier, password: Boolean = false, keyboard: KeyboardType = KeyboardType.Text, singleLine: Boolean = true, ) { OutlinedTextField( value, change, label = { Text(label) }, modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(10.dp), singleLine = singleLine, visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None, keyboardOptions = KeyboardOptions(keyboardType = if (password) KeyboardType.Password else keyboard), ) } @Composable private fun EmptyHint(text: String) { Text(text, Modifier.padding(vertical = 20.dp), color = MaterialTheme.colorScheme.onSurfaceVariant) } @Composable private fun PreviewRow(title: String, detail: String) { Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text(title, fontWeight = FontWeight.Medium) if (detail.isNotBlank()) Text(detail, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } @Composable private fun ErrorCard(text: String, dismiss: () -> Unit) { Surface(Modifier.padding(horizontal = 16.dp).fillMaxWidth(), shape = PageShape, color = MaterialTheme.colorScheme.errorContainer) { Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Text(text, Modifier.weight(1f)) TextButton(onClick = dismiss) { Text("Schließen") } } } } @Composable private fun BrandMark() { Canvas(Modifier.size(64.dp).semantics { contentDescription = "Yuvomi" }) { drawRoundRect(Brush.linearGradient(listOf(Color(0xFF8B5CF6), Violet)), cornerRadius = CornerRadius(size.width * .225f)) listOf(Triple(64f, 72f, 27f), Triple(100f, 78f, 25f), Triple(80f, 106f, 24f)).forEach { (x, y, radius) -> drawCircle(Color.White.copy(alpha = .82f), radius * size.width / 160, Offset(x * size.width / 160, y * size.height / 160)) } } } @Composable private fun Glyph(page: NativePage, color: Color) { Canvas(Modifier.size(24.dp)) { val scale = size.width / 24 val path = Path() when (page) { NativePage.HOME -> { path.moveTo(3 * scale, 10 * scale) path.lineTo(12 * scale, 3 * scale) path.lineTo(21 * scale, 10 * scale) path.lineTo(21 * scale, 21 * scale) path.lineTo(3 * scale, 21 * scale) path.close() } NativePage.TASKS -> { path.moveTo(4 * scale, 12 * scale) path.lineTo(10 * scale, 18 * scale) path.lineTo(21 * scale, 5 * scale) } NativePage.SHOPPING -> { path.moveTo(2 * scale, 3 * scale) path.lineTo(5 * scale, 3 * scale) path.lineTo(8 * scale, 16 * scale) path.lineTo(20 * scale, 16 * scale) path.lineTo(22 * scale, 7 * scale) path.lineTo(6 * scale, 7 * scale) drawCircle( color, 1.5f * scale, Offset(9 * scale, 21 * scale), ) drawCircle(color, 1.5f * scale, Offset(19 * scale, 21 * scale)) } NativePage.NOTES -> { path.moveTo(5 * scale, 3 * scale) path.lineTo(19 * scale, 3 * scale) path.lineTo(19 * scale, 21 * scale) path.lineTo(5 * scale, 21 * scale) path.close() path.moveTo(9 * scale, 8 * scale) path.lineTo(15 * scale, 8 * scale) path.moveTo(9 * scale, 13 * scale) path.lineTo(15 * scale, 13 * scale) } NativePage.MORE -> { listOf(5f, 12f, 19f).forEach { drawCircle(color, 1.5f * scale, Offset(it * scale, 12 * scale)) } } } drawPath(path, color, style = Stroke(width = 1.8f * scale)) } }