SHA256
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.
This commit is contained in:
1 parent
81973c3c45
commit
8c480ddca3
28 files changed
+2628
-742
No files matched your search
@@ -0,0 +1,859 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user