SHA256
425 lines
20 KiB
Kotlin
425 lines
20 KiB
Kotlin
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)
|
||
}
|
||
}
|