Files
Yuvomi-App/android/app/src/main/java/community/yuvomi/mobile/NativeApp.kt
T
Yuvomi Mobile Contributors 7ac9882307
Android validation / emulator (37.0) (push) Failing after 3s
Android validation / emulator (35) (push) Failing after 4s
iOS validation / simulator (push) Successful in 59s
Android validation / build (push) Successful in 5m30s
feat(android): align native preview with measured PWA design
2026-10-21 10:45:16 +02:00

425 lines
20 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.background
import androidx.compose.foundation.border
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.json.JSONObject
/** Native controls only. The PWA is a measured design reference, not the renderer. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NativeApp(controller: NativeController, needsLocalNetwork: Boolean = false, requestLocalNetwork: () -> Unit = {}) {
PwaTheme {
val state = controller.state
val colors = LocalPwaPalette.current
var editing by remember { mutableStateOf(false) }
var editor by remember { mutableStateOf<JSONObject?>(null) }
var creatingList by remember { mutableStateOf(false) }
var submitted by remember { mutableStateOf(false) }
var pendingCreate by remember { mutableStateOf<NativePage?>(null) }
var more by remember { mutableStateOf(false) }
var createMenu by remember { mutableStateOf(false) }
var forget by remember { mutableStateOf(false) }
var remove by remember { mutableStateOf<String?>(null) }
var info by remember { mutableStateOf<String?>(null) }
var customize by remember { mutableStateOf(false) }
val layer = rememberGraphicsLayer()
var contentOrigin by remember { mutableStateOf(Offset.Zero) }
fun create(page: NativePage, list: Boolean = false) {
editor = null
creatingList = list
createMenu = false
more = false
if (state.page == page) {
editing = true
} else {
pendingCreate = page
controller.navigate(page)
}
}
LaunchedEffect(state.user?.text("id"), state.server) {
editing = false
editor = null
submitted = false
more = false
createMenu = false
pendingCreate = null
}
LaunchedEffect(state.busy, state.error) {
if (submitted && !state.busy) {
submitted = false
if (state.error == null) editing = false
}
if (pendingCreate != null && !state.busy) {
editing = state.page == pendingCreate && state.error == null
pendingCreate = null
}
}
BackHandler(state.user != null && state.page != NativePage.HOME && !more && !editing) {
if (!state.busy) controller.navigate(NativePage.HOME)
}
Box(Modifier.fillMaxSize().background(colors.background)) {
if (state.user == null) PwaBrandField(Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing).testTag("native-screen")) {
if (state.server.isEmpty() || state.user == null) {
NativeAuthentication(
state, needsLocalNetwork, requestLocalNetwork, controller::connect, controller::login,
controller::verify, controller::cancelTwoFactor, { forget = true },
{
info =
"SSO wird im nativen Client noch nicht unterstützt. Bitte die direkte Yuvomi-Passwortanmeldung verwenden."
},
)
} else {
Box(Modifier.fillMaxSize()) {
Column(
Modifier.fillMaxSize().onGloballyPositioned { contentOrigin = it.positionInRoot() }
.drawWithContent {
layer.record { this@drawWithContent.drawContent() }
drawLayer(layer)
},
) {
if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth().height(2.dp))
state.error?.let { PwaError(it, controller::dismissError) }
when (state.page) {
NativePage.HOME -> NativeDashboard(
state,
controller::navigate,
{ customize = true },
)
NativePage.TASKS, NativePage.NOTES, NativePage.SHOPPING -> NativeCollection(
state,
onEdit = {
editor = it
creatingList = false
editing = true
},
onTask = controller::completeTask, onPin = controller::pinNote, onItem = controller::checkItem,
onList = { controller.openList(it.getLong("id"), it.text("name")) },
onNewList = { create(NativePage.SHOPPING, list = true) },
onRemove = { remove = it }, onRefresh = controller::refresh,
)
NativePage.MORE -> Unit
}
}
PwaBottomNavigation(
state,
layer,
contentOrigin,
onNavigate = { if (it == NativePage.MORE) more = true else controller.navigate(it) },
onCreate = {
if (state.page == NativePage.HOME) {
createMenu = true
} else {
create(state.page, list = state.page == NativePage.SHOPPING && state.listId == null)
}
},
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
}
if (editing) {
NativeEntryEditor(state, editor, creatingList, { if (!state.busy) 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 -> if (creatingList) {
controller.createShoppingList(title)
} else {
controller.saveShopping(editor?.getLong("id"), title, detail)
}
else -> Unit
}
submitted = true
}
}
if (more) {
ModalBottomSheet(
onDismissRequest = { more = false },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = colors.raised,
shape = RoundedCornerShape(topStart = 26.dp, topEnd = 26.dp),
) {
NativeMore(
state,
{
more = false
controller.navigate(it)
},
{
more = false
controller.logout()
},
{
more = false
forget = true
},
)
}
}
if (createMenu) {
ModalBottomSheet(
onDismissRequest = { createMenu = false },
containerColor = colors.raised,
shape = RoundedCornerShape(topStart = 26.dp, topEnd = 26.dp),
) {
Column(Modifier.padding(horizontal = 16.dp).padding(bottom = 24.dp)) {
Text("Neu", style = PwaType.section, modifier = Modifier.padding(bottom = 12.dp))
listOf(
NativePage.TASKS to "Neue Aufgabe",
NativePage.NOTES to "Neue Notiz",
NativePage.SHOPPING to "Neue Einkaufsliste",
)
.filter { state.access(it.first.name.lowercase()) == "write" }.forEach { (page, label) ->
PwaMenuRow(page.pwaGlyph(), label, {
create(page, page == NativePage.SHOPPING)
}, tint = colors.module(page))
}
}
}
}
if (customize) {
NativeDashboardOptions(state, { customize = false }) {
customize = false
controller.saveDashboardVisibility(it)
}
}
if (forget) {
AlertDialog(
onDismissRequest = { forget = false },
shape = RoundedCornerShape(26.dp),
title = { Text("Server entfernen?", style = PwaType.section) },
text = {
Text("Die lokale Sitzung wird gelöscht. Melde dich möglichst zuerst ab, um sie auch auf dem Server zu widerrufen.")
},
confirmButton = {
PwaTextAction("Lokal entfernen", {
forget = false
controller.forgetLocalServer()
}, enabled = !state.busy, color = colors.danger)
},
dismissButton = { PwaTextAction("Abbrechen", { forget = false }) },
)
}
remove?.let { path ->
AlertDialog(
onDismissRequest = { remove = null },
shape = RoundedCornerShape(26.dp),
title = { Text("Eintrag löschen?", style = PwaType.section) },
text = { Text("Der Eintrag wird auf deinem Server gelöscht. Diese Aktion kann nicht zurückgenommen werden.") },
confirmButton = {
PwaTextAction("Löschen", {
remove = null
controller.remove(path)
}, color = colors.danger)
},
dismissButton = { PwaTextAction("Abbrechen", { remove = null }) },
)
}
info?.let { message ->
AlertDialog(
onDismissRequest = { info = null },
shape = RoundedCornerShape(26.dp),
title = { Text("Nativer Client", style = PwaType.section) },
text = { Text(message) },
confirmButton = { PwaTextAction("Verstanden", { info = null }) },
)
}
}
}
}
@Composable
private fun NativeAuthentication(
state: NativeState,
needsNetwork: Boolean,
requestNetwork: () -> Unit,
connect: (String) -> Unit,
login: (String, String, String) -> Unit,
verify: (String) -> Unit,
cancelTwoFactor: () -> Unit,
forget: () -> Unit,
sso: () -> Unit,
) {
val colors = LocalPwaPalette.current
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 invalid by remember { mutableStateOf(false) }
LaunchedEffect(state.server) {
username = ""
password = ""
code = ""
name = ""
}
Column(
Modifier.fillMaxSize().imePadding().verticalScroll(rememberScrollState()).padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
PwaCard(
Modifier.widthIn(max = 412.dp).fillMaxWidth().testTag("auth-panel")
.border(1.dp, Color.White.copy(alpha = if (colors.dark) .12f else .65f), RoundedCornerShape(26.dp)),
color = (if (colors.dark) Color(0xFF2D2A27) else Color(0xFFFBFAF7)).copy(alpha = if (colors.dark) .88f else .86f),
radius = 26.dp,
) {
Column(Modifier.padding(horizontal = 17.dp).padding(top = 25.dp, bottom = 16.dp)) {
Column(Modifier.fillMaxWidth().padding(bottom = 24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
PwaBrandMark(Modifier.padding(bottom = 22.dp))
Text(state.appName, style = PwaType.largeTitle, color = colors.text, textAlign = TextAlign.Center)
Spacer(Modifier.height(8.dp))
Text(
if (state.server.isEmpty()) {
"Dein Zuhause. Dein Server."
} else if (state.twoFactor) {
"Bestätige deine Anmeldung."
} else {
"Willkommen zurück bei deinem Familienplaner."
},
style = PwaType.field.copy(lineHeight = 25.6.sp),
color = colors.secondary,
textAlign = TextAlign.Center,
)
}
if (state.server.isEmpty()) {
PwaField(address, {
address = it
invalid = false
}, "Server-Adresse", Modifier.testTag("server-url"), keyboard = KeyboardType.Uri, placeholder = "https://")
if (invalid) {
Text(
"Bitte eine gültige HTTPS-Adresse ohne Unterpfad eingeben.",
style = PwaType.caption,
color = colors.danger,
)
}
Spacer(Modifier.height(16.dp))
PwaButton("Verbinden", {
keyboard?.hide()
if (runCatching { ServerAddress.parse(address) }.isSuccess) connect(address) else invalid = true
}, Modifier.fillMaxWidth().testTag("connect"), !state.busy && address.isNotBlank())
if (needsNetwork) PwaTextAction("Zugriff auf private Netzwerke erlauben", requestNetwork)
} else if (state.twoFactor) {
PwaField(code, { code = it }, "Code oder Wiederherstellungscode", Modifier.testTag("two-factor-code"))
Spacer(Modifier.height(16.dp))
PwaButton(
"Bestätigen",
{
keyboard?.hide()
val value = code
code = ""
verify(value)
},
Modifier.fillMaxWidth(),
!state.busy && code.isNotBlank(),
)
PwaTextAction("Zurück zur Anmeldung", cancelTwoFactor, enabled = !state.busy)
} else {
if (state.setup) {
PwaField(name, { name = it }, "Anzeigename")
Spacer(Modifier.height(16.dp))
}
if (state.passwordAllowed || state.setup) {
PwaField(username, { username = it }, "Benutzername", Modifier.testTag("username"))
Spacer(Modifier.height(16.dp))
PwaField(password, { password = it }, "Passwort", Modifier.testTag("password"), password = true)
Spacer(Modifier.height(16.dp))
PwaButton(if (state.setup) "Haushalt einrichten" else "Anmelden", {
keyboard?.hide()
val value = password
password = ""
login(username, value, name)
}, Modifier.fillMaxWidth().testTag("login"), !state.busy && username.isNotBlank() && password.isNotEmpty())
}
if (state.oidc) PwaTextAction("SSO – noch in Umsetzung", sso, Modifier.align(Alignment.CenterHorizontally))
}
state.error?.let {
Text(it, style = PwaType.caption, color = colors.danger, modifier = Modifier.padding(top = 12.dp).testTag("auth-error"))
}
if (state.busy) {
Box(Modifier.fillMaxWidth().padding(top = 12.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
}
}
if (state.server.isNotEmpty() && !state.twoFactor) {
PwaTextAction(
"Anderen Server verwenden",
forget,
Modifier.align(Alignment.CenterHorizontally).padding(top = 2.dp),
!state.busy,
colors.tertiary,
)
} else {
Spacer(Modifier.height(26.dp))
}
}
}
}
}
@Composable
fun PwaError(message: String, dismiss: () -> Unit) {
val colors = LocalPwaPalette.current
Row(
Modifier.fillMaxWidth().background(colors.danger.copy(alpha = .08f)).padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(message, Modifier.weight(1f), style = PwaType.caption, color = colors.danger)
PwaIconButton(PwaGlyph.CLOSE, "Fehler schließen", dismiss, outlined = false, tint = colors.danger)
}
}