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(null) } var creatingList by remember { mutableStateOf(false) } var submitted by remember { mutableStateOf(false) } var pendingCreate by remember { mutableStateOf(null) } var more by remember { mutableStateOf(false) } var createMenu by remember { mutableStateOf(false) } var forget by remember { mutableStateOf(false) } var remove by remember { mutableStateOf(null) } var info by remember { mutableStateOf(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) } }