Files
Yuvomi-App/android/app/src/main/java/community/yuvomi/mobile/NativeApp.kt
T
Yuvomi Mobile Contributors 8c480ddca3 feat(android): replace WebView with tested native Compose API slice
Add real native onboarding/auth/dashboard/tasks/notes/shopping, origin-bound HTTPS and encrypted AndroidKeyStore session persistence. Archive the superseded browser renderer and tests. Verify 10 JVM tests, 6 native API-35 emulator tests, lintDebug/lintRelease and debug/unsigned release APK/AAB builds; the real upstream suite passes 7 tests and exports a nonblank native fixture screenshot. Tink host-tool update removes the old generated-protobuf warning. Full module/visual parity remains explicitly incomplete.
2026-10-21 09:12:47 +02:00

860 lines
40 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<JSONObject?>(null) }
var editing by remember { mutableStateOf(false) }
var submitted by remember { mutableStateOf(false) }
var remove by remember { mutableStateOf<String?>(null) }
var info by remember { mutableStateOf<String?>(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))
}
}