feat(android): align native preview with measured PWA design
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

This commit is contained in:
Yuvomi Mobile Contributors committed 2026-10-21 10:45:16 +02:00
1 parent bd0e4cc4fb
commit 7ac9882307
30 files changed
+2793 -750

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -31,6 +31,8 @@ data class NativeState(
val resetAllowed: Boolean = false,
val page: NativePage = NativePage.HOME,
val dashboard: JSONObject = JSONObject(),
val preferences: JSONObject = JSONObject(),
val shoppingLists: List<JSONObject> = emptyList(),
val rows: List<JSONObject> = emptyList(),
val listId: Long? = null,
val listName: String = "",
@@ -77,6 +79,8 @@ class NativeController(application: Application) : AndroidViewModel(application)
user = null,
permissions = JSONObject(),
dashboard = JSONObject(),
preferences = JSONObject(),
shoppingLists = emptyList(),
rows = emptyList(),
page = NativePage.HOME,
listId = null,
@@ -168,6 +172,12 @@ class NativeController(application: Application) : AndroidViewModel(application)
requireNotNull(client).requireSessionCookie()
val user = response.getJSONObject("user")
val permissions = response.getJSONObject("permissions")
val preferences = try {
requireNotNull(client).request("/preferences").optJSONObject("data") ?: JSONObject()
} catch (error: Exception) {
if (error is NativeApiError && error.status == 401) throw error
JSONObject()
}
val signedIn = previous.copy(
user = user,
permissions = permissions,
@@ -175,6 +185,8 @@ class NativeController(application: Application) : AndroidViewModel(application)
twoFactor = false,
page = NativePage.HOME,
dashboard = JSONObject(),
preferences = preferences,
shoppingLists = emptyList(),
rows = emptyList(),
)
return try {
@@ -196,7 +208,16 @@ class NativeController(application: Application) : AndroidViewModel(application)
NativePage.HOME -> previous.copy(dashboard = api.request("/dashboard"))
NativePage.TASKS -> previous.copy(rows = api.request("/tasks").rows())
NativePage.NOTES -> previous.copy(rows = api.request("/notes").rows())
NativePage.SHOPPING -> previous.copy(rows = api.request(previous.listId?.let { "/shopping/$it/items" } ?: "/shopping").rows())
NativePage.SHOPPING -> {
val lists = api.request("/shopping").rows()
val selected = lists.firstOrNull { it.optLong("id") == previous.listId } ?: lists.firstOrNull()
previous.copy(
shoppingLists = lists,
listId = selected?.getLong("id"),
listName = selected?.text("name").orEmpty(),
rows = selected?.let { api.request("/shopping/${it.getLong("id")}/items").rows() }.orEmpty(),
)
}
NativePage.MORE -> previous
}
}
@@ -270,6 +291,21 @@ class NativeController(application: Application) : AndroidViewModel(application)
load(previous)
}
fun createShoppingList(name: String) = perform { previous ->
val list = requireNotNull(client).request("/shopping", "POST", JSONObject().put("name", name.trim())).getJSONObject("data")
load(previous.copy(listId = list.getLong("id")))
}
fun saveDashboardVisibility(visible: Set<String>) = perform { previous ->
// A failed preference read must never overwrite unknown stored widgets.
check(previous.preferences.has("dashboard_widgets"))
val editable = pwaSupportedWidgets.filter { previous.moduleVisible(it) && previous.widget(it) }.toSet()
val widgets = pwaWidgetVisibility(previous.preferences, visible, editable)
requireNotNull(client).request("/preferences", "PUT", JSONObject().put("dashboard_widgets", widgets))
val preferences = requireNotNull(client).request("/preferences").getJSONObject("data")
load(previous.copy(preferences = preferences))
}
fun checkItem(item: JSONObject) = perform { previous ->
requireNotNull(client).request(
"/shopping/items/${item.getLong("id")}",
@@ -309,6 +345,8 @@ class NativeController(application: Application) : AndroidViewModel(application)
user = null,
permissions = JSONObject(),
dashboard = JSONObject(),
preferences = JSONObject(),
shoppingLists = emptyList(),
rows = emptyList(),
listId = null,
page = NativePage.HOME,
@@ -0,0 +1,156 @@
package community.yuvomi.mobile
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
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.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import java.time.LocalDate
import org.json.JSONObject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NativeEntryEditor(
state: NativeState,
entry: JSONObject?,
creatingList: Boolean,
dismiss: () -> Unit,
save: (String, String, String, String) -> Unit,
) {
val colors = LocalPwaPalette.current
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
val titleText = if (entry != null) {
"Bearbeiten"
} else {
when (state.page) {
NativePage.TASKS -> "Neue Aufgabe"
NativePage.NOTES -> "Neue Notiz"
NativePage.SHOPPING -> if (creatingList) "Neue Einkaufsliste" else "Neuer Artikel"
else -> "Hinzufügen"
}
}
ModalBottomSheet(
onDismissRequest = { if (!state.busy) dismiss() },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = colors.raised,
shape = RoundedCornerShape(topStart = 26.dp, topEnd = 26.dp),
dragHandle = null,
) {
Column(Modifier.fillMaxWidth().imePadding().heightIn(max = 720.dp)) {
Row(
Modifier.fillMaxWidth().padding(start = 20.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(titleText, Modifier.weight(1f), style = PwaType.section)
PwaIconButton(PwaGlyph.CLOSE, "Schließen", dismiss, enabled = !state.busy, outlined = false, tint = colors.secondary)
}
HorizontalDivider(color = colors.border)
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
state.error?.let { Text(it, style = PwaType.caption, color = colors.danger) }
PwaField(title, { title = it }, if (state.page == NativePage.SHOPPING) "Name" else "Titel", Modifier.testTag("entry-title"))
if (state.page != NativePage.SHOPPING || !creatingList) {
PwaField(
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", style = PwaType.secondary, color = colors.secondary)
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("none", "low", "medium", "high", "urgent").forEach { value ->
Text(
pwaPriority(value).ifBlank { "Keine" },
Modifier.clip(CircleShape).background(
if (value ==
priority
) {
colors.accent.copy(alpha = .16f)
} else {
colors.well
},
)
.clickable { priority = value }.padding(horizontal = 12.dp, vertical = 10.dp),
style = PwaType.button,
color = if (value == priority) colors.accent else colors.secondary,
)
}
}
PwaField(date, { date = it }, "Fällig am (JJJJ-MM-TT)")
if (!validDate) Text("Bitte ein gültiges Datum eingeben.", style = PwaType.caption, color = colors.danger)
}
}
HorizontalDivider(color = colors.border)
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
PwaTextAction("Abbrechen", dismiss, enabled = !state.busy)
Spacer(Modifier.weight(1f))
PwaButton(
"Speichern",
{
keyboard?.hide()
save(title, detail, priority, date)
},
Modifier.testTag("entry-save"),
validDate && !state.busy && (title.isNotBlank() || state.page == NativePage.NOTES && detail.isNotBlank()) &&
(state.page != NativePage.NOTES || detail.isNotBlank()),
)
}
}
}
}
@@ -0,0 +1,679 @@
package community.yuvomi.mobile
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
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.heightIn
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.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import java.time.format.DateTimeFormatter
import java.util.Locale
import org.json.JSONObject
@Composable
fun NativeDashboard(state: NativeState, navigate: (NativePage) -> Unit, customize: () -> Unit) {
val colors = LocalPwaPalette.current
var search by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
val now = state.householdNow()
BoxWithConstraints(Modifier.fillMaxSize().testTag("native-dashboard")) {
val columns = if (maxWidth >= 768.dp) 2 else 1
val side = if (maxWidth < 640.dp) 12.dp else 16.dp
LazyVerticalGrid(
GridCells.Fixed(columns),
contentPadding = PaddingValues(start = side, end = side, top = 16.dp, bottom = 112.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Column(Modifier.padding(top = 12.dp, bottom = 8.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
now.format(DateTimeFormatter.ofPattern("EEEE, d. MMMM", Locale.GERMAN)).uppercase(Locale.GERMAN),
Modifier.weight(1f),
style = PwaType.label,
color = colors.secondary,
)
PwaIconButton(PwaGlyph.SEARCH, "Übersicht durchsuchen", { search = !search })
PwaIconButton(PwaGlyph.FILTER, "Übersicht anpassen", customize, enabled = !state.busy)
}
Spacer(Modifier.height(4.dp))
Text(
pwaGreeting(
state.user?.text("display_name").orEmpty().ifBlank {
state.user?.text("username").orEmpty()
},
now.hour,
),
style = PwaType.largeTitle,
color = colors.text,
modifier = Modifier.testTag("dashboard-greeting"),
)
if (search) {
Box(Modifier.padding(top = 12.dp)) {
PwaField(query, {
query = it
}, "Übersicht durchsuchen", showLabel = false, placeholder = "In der Übersicht suchen")
}
}
}
}
items(state.dashboardTiles()) { id ->
val data = state.dashboard
when (id) {
"tasks" -> PwaWidget("Aufgaben", PwaGlyph.TASKS, colors.work, data.optInt("openTaskCount"), {
navigate(NativePage.TASKS)
}) {
val rows = data.rows("urgentTasks").filter { it.text("title").contains(query, true) }
if (rows.isEmpty()) {
WidgetEmpty(
PwaGlyph.TASKS,
if (query.isBlank()) "Keine dringenden Aufgaben" else "Keine Treffer",
)
}
rows.forEach { task ->
Row(
Modifier.padding(vertical = 8.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier.padding(top = 7.dp).size(10.dp).clip(CircleShape).background(
if (task.text("priority") ==
"urgent"
) {
colors.danger
} else {
colors.kitchen
},
),
)
Column {
Text(task.text("title"), style = PwaType.secondary, fontWeight = FontWeight.SemiBold)
val due = pwaDueLabel(task.text("due_date"), now.toLocalDate())
if (due.isNotBlank()) Text(due, style = PwaType.caption, color = colors.warning)
}
}
}
}
"calendar" -> PwaWidget("Kalender", PwaGlyph.CALENDAR, colors.time, null, null) {
val events = data.rows("upcomingEvents").filter { it.text("title").contains(query, true) }
if (events.isEmpty()) WidgetEmpty(PwaGlyph.CALENDAR, "Keine Termine")
events.forEach { event ->
Column(Modifier.padding(vertical = 8.dp)) {
Text(event.text("title"), style = PwaType.secondary, fontWeight = FontWeight.SemiBold)
Text(
event.text("start_datetime").replace('T', ' ').take(16),
style = PwaType.caption,
color = colors.secondary,
)
}
}
}
"shopping" -> PwaWidget("Einkauf", PwaGlyph.SHOPPING, colors.kitchen, data.optInt("shoppingOpenCount"), {
navigate(NativePage.SHOPPING)
}) {
val lists = data.rows("shoppingLists").filter { list ->
(
list.text("name") +
list.rows("items").joinToString { it.text("name") }
).contains(query, true)
}
if (lists.isEmpty()) WidgetEmpty(PwaGlyph.SHOPPING, "Alles eingekauft")
lists.forEach { list ->
Row(
Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(list.text("name"), Modifier.weight(1f), style = PwaType.secondary, fontWeight = FontWeight.SemiBold)
Text(
"${list.optInt("total_count") - list.optInt("open_count")}/${list.optInt("total_count")}",
style = PwaType.caption,
color = colors.secondary,
)
}
Box(Modifier.fillMaxWidth().height(4.dp).clip(CircleShape).background(colors.well))
list.rows("items").take(3).forEach { item ->
Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
Modifier.padding(
top = 3.dp,
).size(14.dp).border(1.dp, colors.controlBorder, RoundedCornerShape(4.dp)),
)
Text(item.text("name"), style = PwaType.secondary)
if (item.text(
"quantity",
).isNotBlank()
) {
Text(item.text("quantity"), style = PwaType.caption, color = colors.secondary)
}
}
}
}
}
"notes" -> PwaWidget("Notizen", PwaGlyph.NOTES, colors.records, data.optInt("pinnedNotesCount"), {
navigate(NativePage.NOTES)
}) {
val notes = data.rows("pinnedNotes").filter { (it.text("title") + it.text("content")).contains(query, true) }
if (notes.isEmpty()) WidgetEmpty(PwaGlyph.NOTES, "Keine angepinnten Notizen")
notes.forEach { note ->
Column(Modifier.padding(vertical = 4.dp)) {
if (note.text(
"title",
).isNotBlank()
) {
Text(note.text("title"), style = PwaType.secondary, fontWeight = FontWeight.SemiBold)
}
Text(
note.text("content"),
style = PwaType.caption,
color = colors.secondary,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
}
}
@Composable
private fun PwaWidget(title: String, glyph: PwaGlyph, tint: Color, count: Int?, open: (() -> Unit)?, content: @Composable () -> Unit) {
val colors = LocalPwaPalette.current
PwaCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(1.dp)) {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 8.dp).heightIn(min = 32.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PwaModuleSeal(glyph, tint)
Spacer(Modifier.width(8.dp))
Text(title, style = PwaType.headline, color = colors.text)
if (count != null && count > 0) {
Spacer(Modifier.width(8.dp))
PwaBadge(count.toString())
}
Spacer(Modifier.weight(1f))
if (open != null) PwaTextAction("Alle", open, color = colors.secondary, compact = true)
}
Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 16.dp)) { content() }
}
}
}
@Composable
private fun WidgetEmpty(icon: PwaGlyph, text: String) {
val colors = LocalPwaPalette.current
Row(
Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PwaIcon(icon, colors.tertiary.copy(alpha = .6f))
Text(text, style = PwaType.button, color = colors.tertiary)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NativeCollection(
state: NativeState,
onEdit: (JSONObject) -> Unit,
onTask: (JSONObject) -> Unit,
onPin: (JSONObject) -> Unit,
onItem: (JSONObject) -> Unit,
onList: (JSONObject) -> Unit,
onNewList: () -> Unit,
onRemove: (String) -> Unit,
onRefresh: () -> Unit,
) {
val colors = LocalPwaPalette.current
val write = !state.busy && state.access(state.page.name.lowercase()) == "write"
var search by remember(state.page) { mutableStateOf(false) }
var query by remember(state.page) { mutableStateOf("") }
var filters by remember(state.page) { mutableStateOf(false) }
var status by remember { mutableStateOf("open") }
var onlyPinned by remember { mutableStateOf(false) }
var tools by remember { mutableStateOf(false) }
val rows = state.rows.filter { row ->
val title = if (state.page == NativePage.SHOPPING) row.text("name") else row.text("title")
(query.isBlank() || (title + row.text("description") + row.text("content")).contains(query, true)) &&
(
state.page != NativePage.TASKS || status == "all" ||
if (status == "done") row.text("status") == "done" else row.text("status") != "done"
) &&
(state.page != NativePage.NOTES || !onlyPinned || row.optInt("pinned") == 1)
}
Column(Modifier.fillMaxSize()) {
if (state.page == NativePage.SHOPPING) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Other kitchen modules remain visibly unavailable, never browser fallback routes.
listOf("Mahlzeiten", "Rezepte", "Einkauf", "Vorrat").forEach { label ->
val active = label == "Einkauf"
Box(
Modifier.clip(
CircleShape,
).background(if (active) colors.surface else Color.Transparent).padding(horizontal = 8.dp, vertical = 12.dp),
) {
Text(
label,
style = PwaType.button,
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal,
color = if (active) colors.kitchen else colors.secondary,
)
}
}
}
HorizontalDivider(color = colors.border.copy(alpha = .5f))
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(Modifier.weight(1f).horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
state.shoppingLists.forEach { list ->
val active = state.listId == list.optLong("id")
PwaCard(color = if (active) colors.surface else colors.background, radius = 24.dp) {
Row(
Modifier.clickable(enabled = !state.busy) {
onList(list)
}.heightIn(min = 48.dp).padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
list.text("name"),
style = PwaType.button,
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium,
color = if (active) colors.kitchen else colors.secondary,
)
PwaBadge(list.optInt("item_total").toString())
}
}
}
PwaIconButton(
PwaGlyph.PLUS,
"Neue Liste",
onNewList,
Modifier.testTag("add-list"),
enabled = write,
tint = colors.secondary,
)
}
Box {
PwaIconButton(PwaGlyph.MORE, "Listenaktionen", { tools = true })
DropdownMenu(tools, { tools = false }, containerColor = colors.raised) {
DropdownMenuItem({ Text("Aktualisieren", style = PwaType.secondary) }, {
tools = false
onRefresh()
})
DropdownMenuItem({ Text("Liste löschen", style = PwaType.secondary, color = colors.danger) }, {
tools = false
state.listId?.let { onRemove("/shopping/$it") }
}, enabled = write && state.listId != null)
}
}
}
} else {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
PwaModuleSeal(state.page.pwaGlyph(), colors.module(state.page), 32.dp)
Text(state.page.title, Modifier.weight(1f), style = PwaType.largeTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
PwaIconButton(PwaGlyph.SEARCH, "Suchen", { search = !search })
PwaIconButton(PwaGlyph.FILTER, "Filter", { filters = true })
if (state.page == NativePage.TASKS) {
Box {
PwaIconButton(PwaGlyph.MORE, "Aufgabenaktionen", { tools = true })
DropdownMenu(tools, { tools = false }, containerColor = colors.raised) {
DropdownMenuItem({ Text("Aktualisieren", style = PwaType.secondary) }, {
tools = false
onRefresh()
})
DropdownMenuItem({ Text("Alle Aufgaben", style = PwaType.secondary) }, {
tools = false
status = "all"
})
}
}
}
}
}
HorizontalDivider(color = colors.border)
if (search) {
Box(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
PwaField(query, { query = it }, "Suchen", placeholder = "Suchen", showLabel = false)
}
}
LazyColumn(
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = if (state.page ==
NativePage.SHOPPING
) {
0.dp
} else {
12.dp
},
bottom = 112.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (rows.isEmpty() && !state.busy) {
item {
Column(
Modifier.fillMaxWidth().padding(vertical = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
PwaIcon(if (state.page == NativePage.SHOPPING) PwaGlyph.SHOPPING else state.page.pwaGlyph(), colors.tertiary, 32.dp)
Text(
if (query.isBlank()) "Noch keine Einträge" else "Keine Treffer",
style = PwaType.headline,
color = colors.secondary,
)
Text(
if (write) "Neue Einträge erstellst du mit +." else "Du hast hier Lesezugriff.",
style = PwaType.caption,
color = colors.tertiary,
)
}
}
}
if (state.page == NativePage.NOTES) {
items(
rows.sortedByDescending {
it.optInt("pinned")
},
key = {
it.getLong("id")
},
) { note -> NativeNoteCard(note, write, { onEdit(note) }, { onPin(note) }, { onRemove("/notes/${note.getLong("id")}") }) }
} else {
rows.groupBy { pwaGroup(it.text("category")) }.forEach { (group, groupRows) ->
item(key = "group-$group") {
Row(
Modifier.fillMaxWidth().heightIn(min = if (state.page == NativePage.SHOPPING) 16.dp else 20.dp)
.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
PwaIcon(PwaGlyph.CHEVRON_DOWN, colors.secondary, 16.dp)
Text(group.uppercase(Locale.GERMAN), style = PwaType.label, color = colors.secondary)
Text(groupRows.size.toString(), style = PwaType.caption, color = colors.tertiary)
}
}
item(key = "rows-$group") {
PwaCard(Modifier.fillMaxWidth()) {
Column {
groupRows.forEachIndexed { index, row ->
if (index >
0
) {
HorizontalDivider(
color = colors.border.copy(alpha = .6f),
modifier = Modifier.padding(start = 48.dp),
)
}
NativeListRow(state.page, row, write, { onEdit(row) }, {
if (state.page ==
NativePage.TASKS
) {
onTask(row)
} else {
onItem(row)
}
}, {
onRemove(
if (state.page ==
NativePage.TASKS
) {
"/tasks/${row.getLong("id")}"
} else {
"/shopping/items/${row.getLong("id")}"
},
)
})
}
}
}
}
}
}
}
}
if (filters) {
ModalBottomSheet(onDismissRequest = { filters = false }, containerColor = colors.raised) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Filter", style = PwaType.section)
if (state.page == NativePage.TASKS) {
listOf("open" to "Offen", "done" to "Erledigt", "all" to "Alle").forEach { (value, label) ->
Row(verticalAlignment = Alignment.CenterVertically) {
if (status == value) PwaIcon(PwaGlyph.CHECK, colors.accent, 16.dp)
PwaTextAction(label, {
status = value
filters = false
})
}
}
} else {
PwaTextAction(if (onlyPinned) "Alle Notizen" else "Nur angepinnte Notizen", {
onlyPinned = !onlyPinned
filters = false
})
}
Spacer(Modifier.height(16.dp))
}
}
}
}
@Composable
private fun NativeListRow(page: NativePage, row: JSONObject, write: Boolean, edit: () -> Unit, toggle: () -> Unit, remove: () -> Unit) {
val colors = LocalPwaPalette.current
var menu by remember { mutableStateOf(false) }
val checked = if (page == NativePage.TASKS) row.text("status") == "done" else row.optInt("is_checked") == 1
val shopping = page == NativePage.SHOPPING
Row(
Modifier.fillMaxWidth().padding(horizontal = if (shopping) 20.dp else 8.dp, vertical = 8.dp),
verticalAlignment = if (shopping) Alignment.CenterVertically else Alignment.Top,
) {
Box(
Modifier.size(32.dp).clip(CircleShape).toggleable(checked, enabled = write, role = Role.Checkbox) { toggle() }
.semantics { contentDescription = row.text(if (page == NativePage.SHOPPING) "name" else "title") },
contentAlignment = Alignment.Center,
) {
Box(
Modifier.size(
if (shopping) 24.dp else 20.dp,
).clip(
CircleShape,
).background(
if (checked) colors.work else Color.Transparent,
).border(2.dp, if (checked) colors.work else colors.tertiary, CircleShape),
contentAlignment = Alignment.Center,
) {
if (checked) PwaIcon(PwaGlyph.CHECK, colors.vividInk, 14.dp)
}
}
Spacer(Modifier.width(if (shopping) 20.dp else 8.dp))
Column(Modifier.weight(1f).clickable(enabled = write, onClick = edit).padding(top = 1.dp, bottom = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
if (page ==
NativePage.SHOPPING
) {
row.text("name")
} else {
row.text("title")
},
style = PwaType.rowTitle,
textDecoration = if (checked) TextDecoration.LineThrough else null,
)
if (shopping &&
row.text("quantity").isNotBlank()
) {
Text(row.text("quantity"), style = PwaType.secondary, color = colors.secondary)
}
}
if (page == NativePage.TASKS) {
val priority = pwaPriority(row.text("priority"))
val due = pwaDueLabel(row.text("due_date"))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
if (priority.isNotBlank()) {
Box(
Modifier.size(8.dp).clip(CircleShape).background(
if (row.text("priority") ==
"urgent"
) {
colors.danger
} else {
colors.kitchen
},
),
)
Text(priority, style = PwaType.caption, color = colors.secondary)
}
if (due.isNotBlank()) Text(due, style = PwaType.caption, color = colors.warning, fontWeight = FontWeight.SemiBold)
}
}
}
if (write) {
Box {
PwaIconButton(PwaGlyph.MORE, "Weitere Aktionen", { menu = true }, outlined = false, tint = colors.tertiary, size = 40.dp)
DropdownMenu(menu, { menu = false }, containerColor = colors.raised) {
DropdownMenuItem({ Text("Bearbeiten", style = PwaType.secondary) }, {
menu = false
edit()
})
DropdownMenuItem({ Text("Löschen", style = PwaType.secondary, color = colors.danger) }, {
menu = false
remove()
})
}
}
}
// Upstream reserves the drag-handle column even for a single task.
if (!shopping) Spacer(Modifier.width(34.dp))
}
}
@Composable
private fun NativeNoteCard(note: JSONObject, write: Boolean, edit: () -> Unit, pin: () -> Unit, remove: () -> Unit) {
val colors = LocalPwaPalette.current
var menu by remember { mutableStateOf(false) }
PwaCard(Modifier.fillMaxWidth(), color = pwaNoteColor(note, colors)) {
Box {
Column(Modifier.padding(12.dp)) {
Text(
note.text("title"),
Modifier.fillMaxWidth().padding(end = 32.dp).clickable(enabled = write, onClick = edit),
style = PwaType.headline,
)
Spacer(Modifier.height(4.dp))
Text(
note.text("content"),
Modifier.fillMaxWidth().clickable(enabled = write, onClick = edit),
style = PwaType.body.copy(lineHeight = 27.2.sp),
maxLines = 12,
overflow = TextOverflow.Ellipsis,
)
HorizontalDivider(color = colors.border.copy(alpha = .6f), modifier = Modifier.padding(top = 8.dp, bottom = 4.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
val avatar =
note.text("creator_color").takeIf {
it.matches(Regex("#[0-9a-fA-F]{6}"))
}?.let { Color(it.toColorInt()) }
?: colors.records
Box(Modifier.size(16.dp).clip(CircleShape).background(avatar))
Spacer(Modifier.width(4.dp))
Text(note.text("creator_name"), Modifier.weight(1f), style = PwaType.caption, color = colors.secondary)
if (write) {
Box {
PwaIconButton(PwaGlyph.MORE, "Notizaktionen", {
menu = true
}, outlined = false, tint = colors.tertiary, size = 24.dp)
DropdownMenu(menu, { menu = false }, containerColor = colors.raised) {
DropdownMenuItem({ Text("Bearbeiten", style = PwaType.secondary) }, {
menu = false
edit()
})
DropdownMenuItem({ Text("Löschen", style = PwaType.secondary, color = colors.danger) }, {
menu = false
remove()
})
}
}
}
}
}
PwaIconButton(
if (note.optInt("pinned") == 1) PwaGlyph.PIN_FILLED else PwaGlyph.PIN,
if (note.optInt("pinned") == 1) "Lösen" else "Anheften",
pin,
Modifier.align(Alignment.TopEnd).padding(top = 8.dp, end = 8.dp),
enabled = write,
outlined = false,
tint = if (note.optInt("pinned") == 1) colors.text else colors.tertiary,
size = 32.dp,
)
}
}
}
@@ -0,0 +1,378 @@
package community.yuvomi.mobile
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.shadow
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.SolidColor
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
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.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
/** Literal roles from pinned PWA tokens.css / typography.css, not Material defaults. */
object PwaType {
private fun role(size: Float, weight: FontWeight = FontWeight.Normal, height: Float = 1.47f, tracking: Float = 0f) = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = size.sp,
fontWeight = weight,
lineHeight = (size * height).sp,
letterSpacing = tracking.sp,
platformStyle = PlatformTextStyle(includeFontPadding = false),
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None),
)
val largeTitle = role(34f, FontWeight.Bold, 1.21f, -.51f)
val toolbar = role(22f, FontWeight.Bold, 1.21f)
val section = role(20f, FontWeight.SemiBold, 1.3f)
val headline = role(17f, FontWeight.SemiBold, 1.3f)
val body = role(17f)
val rowTitle = role(16f, FontWeight.Medium, 1.3f)
val secondary = role(15f)
val caption = role(13f)
val micro = role(11f, FontWeight.Medium, 1.2f)
val label = role(12f, FontWeight.SemiBold, 1.47f, .6f)
val field = role(16f, height = 1.3f)
val button = role(14f, FontWeight.Medium)
val badge = role(12f, FontWeight.Bold, 1f)
}
@Immutable
data class PwaPalette(
val dark: Boolean,
val background: Color,
val surface: Color,
val raised: Color,
val well: Color,
val text: Color,
val secondary: Color,
val tertiary: Color,
val border: Color,
val controlBorder: Color,
val field: Color,
val accent: Color,
val button: Color,
val vividInk: Color,
val work: Color,
val time: Color,
val kitchen: Color,
val records: Color,
val danger: Color,
val warning: Color,
) {
fun module(page: NativePage) = when (page) {
NativePage.HOME -> accent
NativePage.TASKS -> work
NativePage.SHOPPING -> kitchen
NativePage.NOTES -> records
NativePage.MORE -> tertiary
}
companion object {
fun forTheme(dark: Boolean) = if (dark) {
PwaPalette(
true, Color(0xFF191816), Color(0xFF2B2825), Color(0xFF37332E), Color(0xFF37332E),
Color(0xFFF5F3ED), Color(0xFFB4AEA5), Color(0xFFA9A39A), Color(0xFF47423B), Color(0xFF857F76),
Color(0xFF37332E), Color(0xFFA78BFA), Color(0xFFBBA6F9), Color(0xFF0A0A0C),
Color(0xFF4ADE80), Color(0xFF4CB8F0), Color(0xFFFB923C), Color(0xFF7E9BCC),
Color(0xFFFF6961), Color(0xFFFF9F0A),
)
} else {
PwaPalette(
false, Color(0xFFF5F3ED), Color.White, Color(0xFFFBFAF7), Color(0xFFEDEAE3),
Color(0xFF1D1B17), Color(0xFF63615B), Color(0xFF6B675F), Color(0xFFE4E0D7), Color(0xFF8C8880),
Color.White, Color(0xFF6C3AED), Color(0xFF6135D2), Color.White,
Color(0xFF157F3D), Color(0xFF00668F), Color(0xFFC2410C), Color(0xFF42587E),
Color(0xFFD70015), Color(0xFFA85D00),
)
}
}
}
val LocalPwaPalette = staticCompositionLocalOf { PwaPalette.forTheme(false) }
@Composable
fun PwaTheme(content: @Composable () -> Unit) {
val palette = PwaPalette.forTheme(isSystemInDarkTheme())
val scheme = if (palette.dark) darkColorScheme() else lightColorScheme()
CompositionLocalProvider(LocalPwaPalette provides palette, LocalContentColor provides palette.text) {
MaterialTheme(
colorScheme = scheme.copy(
primary = palette.accent, onPrimary = palette.vividInk,
background = palette.background, onBackground = palette.text,
surface = palette.surface, onSurface = palette.text, onSurfaceVariant = palette.secondary,
surfaceVariant = palette.well, outline = palette.controlBorder, outlineVariant = palette.border,
error = palette.danger,
),
typography = Typography(
bodyLarge = PwaType.body,
bodyMedium = PwaType.secondary,
bodySmall = PwaType.caption,
titleLarge = PwaType.toolbar,
titleMedium = PwaType.headline,
headlineLarge = PwaType.largeTitle,
labelLarge = PwaType.button,
labelSmall = PwaType.micro,
),
content = content,
)
}
}
@Composable
fun PwaCard(
modifier: Modifier = Modifier,
color: Color = LocalPwaPalette.current.surface,
radius: Dp = 12.dp,
content: @Composable () -> Unit,
) {
val shape = RoundedCornerShape(radius)
Surface(
modifier.shadow(6.dp, shape, ambientColor = Color.Black.copy(alpha = .28f), spotColor = Color.Black.copy(alpha = .28f)),
shape = shape,
color = color,
tonalElevation = 0.dp,
) { content() }
}
@Composable
fun PwaButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
val colors = LocalPwaPalette.current
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 48.dp),
shape = CircleShape,
colors = ButtonDefaults.buttonColors(containerColor = colors.button, contentColor = colors.vividInk),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) { Text(text, style = PwaType.button) }
}
@Composable
fun PwaIconButton(
icon: PwaGlyph,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
outlined: Boolean = true,
tint: Color = LocalPwaPalette.current.accent,
size: Dp = 48.dp,
) {
val colors = LocalPwaPalette.current
Box(
modifier.size(size).clip(CircleShape)
.then(if (outlined) Modifier.border(1.dp, colors.border, CircleShape) else Modifier)
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
.semantics { contentDescription = label },
contentAlignment = Alignment.Center,
) { PwaIcon(icon, if (enabled) tint else colors.tertiary, 20.dp) }
}
@Composable
fun PwaField(
value: String,
change: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
password: Boolean = false,
keyboard: KeyboardType = KeyboardType.Text,
singleLine: Boolean = true,
placeholder: String = "",
showLabel: Boolean = true,
) {
val colors = LocalPwaPalette.current
var revealed by remember { mutableStateOf(false) }
val interaction = remember { MutableInteractionSource() }
val focused by interaction.collectIsFocusedAsState()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (showLabel) Text(label, style = PwaType.button, color = colors.secondary)
BasicTextField(
value, change, modifier.fillMaxWidth().semantics { contentDescription = label },
textStyle = PwaType.field.copy(color = colors.text),
singleLine = singleLine,
keyboardOptions = KeyboardOptions(keyboardType = if (password) KeyboardType.Password else keyboard),
visualTransformation = if (password && !revealed) PasswordVisualTransformation() else VisualTransformation.None,
cursorBrush = SolidColor(colors.accent), interactionSource = interaction,
decorationBox = { input ->
Row(
Modifier.fillMaxWidth().heightIn(min = if (singleLine) 48.dp else 104.dp)
.clip(RoundedCornerShape(10.dp)).background(colors.field)
.border(
if (focused) 2.dp else 1.dp,
if (focused) colors.accent else colors.controlBorder,
RoundedCornerShape(10.dp),
)
.padding(start = 12.dp, end = if (password) 0.dp else 12.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.weight(1f)) {
if (value.isEmpty()) Text(placeholder, style = PwaType.field, color = colors.tertiary)
input()
}
if (password) {
PwaIconButton(
if (revealed) PwaGlyph.EYE_OFF else PwaGlyph.EYE,
if (revealed) "Passwort verbergen" else "Passwort anzeigen",
{ revealed = !revealed },
outlined = false,
tint = colors.secondary,
size = 32.dp,
)
}
}
},
)
}
}
@Composable
fun PwaModuleSeal(icon: PwaGlyph, color: Color, size: Dp = 24.dp, iconSize: Dp = 20.dp) {
val colors = LocalPwaPalette.current
Box(
Modifier.size(size).clip(CircleShape).background(color)
.drawWithContent {
drawRect(
Brush.verticalGradient(
listOf(Color.White.copy(alpha = if (colors.dark) .16f else .35f), Color.Transparent),
endY = this.size.height * .55f,
),
)
drawContent()
},
contentAlignment = Alignment.Center,
) { PwaIcon(icon, colors.vividInk, iconSize) }
}
@Composable
fun PwaBadge(text: String) {
val colors = LocalPwaPalette.current
Box(Modifier.defaultMinSize(minWidth = 16.dp).clip(CircleShape).background(colors.well).padding(horizontal = 4.dp, vertical = 2.dp)) {
Text(text, style = PwaType.badge, color = colors.secondary)
}
}
@Composable
fun PwaTextAction(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
color: Color = LocalPwaPalette.current.accent,
compact: Boolean = false,
) {
Box(
modifier.clip(CircleShape).clickable(enabled = enabled, role = Role.Button, onClick = onClick)
.padding(horizontal = 8.dp, vertical = if (compact) 5.5.dp else 10.dp),
) {
Text(text, style = PwaType.button, color = color)
}
}
@Composable
fun PwaBrandMark(modifier: Modifier = Modifier) {
Canvas(modifier.size(64.dp).semantics { contentDescription = "Yuvomi" }) {
drawRoundRect(
Brush.linearGradient(listOf(Color(0xFF8B5CF6), Color(0xFF6C3AED))),
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
fun PwaBrandField(modifier: Modifier = Modifier) {
val colors = LocalPwaPalette.current
Canvas(modifier.background(colors.background)) {
val opacity = if (colors.dark) .26f else .20f
val stops = listOf(1f, .98f, .9f, .79f, .65f, .5f, .35f, .21f, .1f, .02f, 0f)
listOf(
Triple(Offset(size.width / 2 - 208.dp.toPx(), size.height / 2 - 192.dp.toPx()), 384.dp.toPx(), Color(0xFF8B5CF6)),
Triple(Offset(size.width / 2 + 240.dp.toPx(), size.height / 2 - 16.dp.toPx()), 352.dp.toPx(), Color(0xFF6C3AED)),
Triple(Offset(size.width / 2 - 16.dp.toPx(), size.height / 2 + 272.dp.toPx()), 368.dp.toPx(), Color(0xFF8B5CF6)),
).asReversed().forEachIndexed { index, (center, radius, color) ->
drawRect(
Brush.radialGradient(
*stops.mapIndexed { i, stop ->
i / 10f to color.copy(
alpha = stop * opacity * if (index ==
0
) {
.8f
} else {
1f
},
)
}.toTypedArray(),
center = center,
radius = radius,
),
)
}
}
}
fun pwaNoteColor(note: org.json.JSONObject, palette: PwaPalette): Color {
val raw = note.text("color").takeIf { it.matches(Regex("#[0-9a-fA-F]{6}")) } ?: "#FFEB3B"
val color = Color(raw.toColorInt())
return lerp(palette.surface, color, .16f)
}
@@ -0,0 +1,151 @@
package community.yuvomi.mobile
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/** Module geometry is adapted verbatim from upstream public/nav-icons.js (MIT).
* Utilities use the PWA's Lucide-style 24-unit geometry, never emoji or Material icons.
*/
enum class PwaGlyph {
OVERVIEW,
TASKS,
NOTES,
SHOPPING,
KITCHEN,
CALENDAR,
MORE,
SEARCH,
FILTER,
PLUS,
CHEVRON,
CHEVRON_DOWN,
CHECK,
TRASH,
PIN,
PIN_FILLED,
REFRESH,
CLOSE,
EYE,
EYE_OFF,
BACK,
SERVER,
}
private data class IconRect(val x: Float, val y: Float, val width: Float, val height: Float, val radius: Float)
private data class IconCircle(val x: Float, val y: Float, val radius: Float, val solid: Boolean = false)
private data class IconShape(
val paths: List<String> = emptyList(),
val rects: List<IconRect> = emptyList(),
val circles: List<IconCircle> = emptyList(),
)
private fun shape(glyph: PwaGlyph): IconShape = when (glyph) {
PwaGlyph.OVERVIEW -> IconShape(
rects = listOf(
IconRect(3.5f, 3.5f, 7.5f, 7.5f, 2.2f),
IconRect(13f, 3.5f, 7.5f, 5f, 2f),
IconRect(13f, 10.5f, 7.5f, 10f, 2.2f),
IconRect(3.5f, 12.5f, 7.5f, 8f, 2f),
),
)
PwaGlyph.TASKS -> IconShape(listOf("m8 12.3 2.8 2.8 5.6-5.6"), listOf(IconRect(3.5f, 3.5f, 17f, 17f, 4.5f)))
PwaGlyph.NOTES -> IconShape(
listOf(
"M5.5 4.5h9.5L18.5 8v11a1.5 1.5 0 0 1-1.5 1.5H5.5A1.5 1.5 0 0 1 4 19V6a1.5 1.5 0 0 1 1.5-1.5z",
"M15 4.5V7a1.5 1.5 0 0 0 1.5 1.5H19",
"M8 13h7M8 16.5h5",
),
)
PwaGlyph.SHOPPING -> IconShape(
listOf("M3 4.5h2.4L7.5 16h10.2l2-7H7"),
circles = listOf(IconCircle(9f, 19.2f, 1.4f), IconCircle(17f, 19.2f, 1.4f)),
)
PwaGlyph.KITCHEN -> IconShape(
listOf("M7.5 3.5v8a2 2 0 0 1-2 2h-.5v7", "M5.5 3.5v6M7.5 3.5v6M9.5 3.5v6", "M17 3.5c-1.8 0-3 1.5-3 3.5v5h2.2v8.5h1.6V3.5z"),
)
PwaGlyph.CALENDAR -> IconShape(
listOf("M3.5 10h17", "M8 3.2v4.6M16 3.2v4.6"),
listOf(IconRect(3.5f, 5.5f, 17f, 15f, 3f)),
listOf(IconCircle(8.5f, 14.5f, .9f, true), IconCircle(12f, 14.5f, .9f, true), IconCircle(15.5f, 14.5f, .9f, true)),
)
PwaGlyph.MORE -> IconShape(circles = listOf(IconCircle(5.5f, 12f, 1.7f), IconCircle(12f, 12f, 1.7f), IconCircle(18.5f, 12f, 1.7f)))
PwaGlyph.SEARCH -> IconShape(listOf("m21 21-4.34-4.34"), circles = listOf(IconCircle(11f, 11f, 8f)))
PwaGlyph.FILTER -> IconShape(listOf("M4 7h9M17 7h3M4 17h3M11 17h9"), circles = listOf(IconCircle(15f, 7f, 2f), IconCircle(9f, 17f, 2f)))
PwaGlyph.PLUS -> IconShape(listOf("M12 5v14M5 12h14"))
PwaGlyph.CHEVRON -> IconShape(listOf("m9 5 7 7-7 7"))
PwaGlyph.CHEVRON_DOWN -> IconShape(listOf("m6 9 6 6 6-6"))
PwaGlyph.CHECK -> IconShape(listOf("m5 12 4 4L19 6"))
PwaGlyph.TRASH -> IconShape(listOf("M3 6h18M9 6V4h6v2M5 6l1 14h12l1-14M10 10v6M14 10v6"))
PwaGlyph.PIN -> IconShape(listOf("M16 9V3H8v6l-3 3v2h14v-2l-3-3M12 14v7"))
PwaGlyph.PIN_FILLED -> IconShape(listOf("M16 9V3H8v6l-3 3v2h14v-2l-3-3Z", "M12 14v7"))
PwaGlyph.REFRESH -> IconShape(listOf("M20 7v5h-5M4 17v-5h5M6.1 7a7 7 0 0 1 11.55-2.6L20 7M4 17l2.35 2.6A7 7 0 0 0 17.9 17"))
PwaGlyph.CLOSE -> IconShape(listOf("M6 6l12 12M18 6 6 18"))
PwaGlyph.EYE -> IconShape(listOf("M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z"), circles = listOf(IconCircle(12f, 12f, 3f)))
PwaGlyph.EYE_OFF -> IconShape(
listOf(
"M3 3l18 18M10.6 10.6a2 2 0 0 0 2.8 2.8M9.9 5.2A11 11 0 0 1 12 5c6.4 0 10 7 10 7a16 16 0 0 1-3.1 3.9" +
"M6.2 6.2A20 20 0 0 0 2 12s3.6 7 10 7a10 10 0 0 0 5.8-1.8",
),
)
PwaGlyph.BACK -> IconShape(listOf("m12 19-7-7 7-7M5 12h14"))
PwaGlyph.SERVER -> IconShape(listOf("M6 7h.01M6 17h.01"), listOf(IconRect(3f, 3f, 18f, 8f, 2f), IconRect(3f, 13f, 18f, 8f, 2f)))
}
@Composable
fun PwaIcon(glyph: PwaGlyph, color: Color, size: Dp = 20.dp, selected: Boolean = false) {
val icon = remember(glyph) { shape(glyph) }
val paths = remember(icon) { icon.paths.map { PathParser().parsePathString(it).toPath() } }
Canvas(Modifier.size(size)) {
val ratio = this.size.width / 24f
val module = glyph in setOf(
PwaGlyph.OVERVIEW,
PwaGlyph.TASKS,
PwaGlyph.NOTES,
PwaGlyph.SHOPPING,
PwaGlyph.KITCHEN,
PwaGlyph.CALENDAR,
PwaGlyph.MORE,
)
val stroke = Stroke(if (module) 1.6f else 2f, cap = StrokeCap.Round, join = StrokeJoin.Round)
scale(ratio, pivot = Offset.Zero) {
icon.rects.forEach {
if (selected) drawRoundRect(color.copy(alpha = .3f), Offset(it.x, it.y), Size(it.width, it.height), CornerRadius(it.radius))
drawRoundRect(color, Offset(it.x, it.y), Size(it.width, it.height), CornerRadius(it.radius), style = stroke)
}
paths.forEachIndexed { index, it ->
if (glyph == PwaGlyph.PIN_FILLED && index == 0) drawPath(it, color)
if (selected) drawPath(it, color.copy(alpha = .3f))
drawPath(it, color, style = stroke)
}
icon.circles.forEach {
if (it.solid) {
drawCircle(color, it.radius, Offset(it.x, it.y))
} else {
drawCircle(color, it.radius, Offset(it.x, it.y), style = stroke)
}
}
}
}
}
fun NativePage.pwaGlyph() = when (this) {
NativePage.HOME -> PwaGlyph.OVERVIEW
NativePage.TASKS -> PwaGlyph.TASKS
NativePage.NOTES -> PwaGlyph.NOTES
NativePage.SHOPPING -> PwaGlyph.KITCHEN
NativePage.MORE -> PwaGlyph.MORE
}
@@ -0,0 +1,107 @@
package community.yuvomi.mobile
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import org.json.JSONArray
import org.json.JSONObject
val pwaSupportedWidgets = listOf("tasks", "calendar", "shopping", "notes")
/** Presentation only. Household business rules and permissions remain server-owned. */
fun pwaGreeting(displayName: String, hour: Int): String {
val first = displayName.trim().split(Regex("\\s+")).firstOrNull().orEmpty()
val greeting = when (hour) {
in 5..11 -> "Guten Morgen"
in 12..17 -> "Guten Tag"
else -> "Guten Abend"
}
return if (first.isBlank()) greeting else "$greeting, $first"
}
fun pwaDueLabel(value: String, today: LocalDate = LocalDate.now()): String {
if (value.isBlank()) return ""
val date = runCatching { LocalDate.parse(value.take(10)) }.getOrNull() ?: return value
return when (date) {
today -> "Heute fällig"
today.plusDays(1) -> "Morgen fällig"
today.minusDays(1) -> "Gestern fällig"
else -> date.format(DateTimeFormatter.ofPattern("d. MMM", Locale.GERMAN))
}
}
fun pwaPriority(value: String) = when (value) {
"low" -> "Niedrig"
"medium" -> "Mittel"
"high" -> "Hoch"
"urgent" -> "Dringend"
else -> ""
}
fun pwaGroup(value: String) = value.takeUnless { it.isBlank() || it in setOf("general", "other", "misc") } ?: "Sonstiges"
fun NativeState.householdNow(instant: java.time.Instant = java.time.Instant.now()): ZonedDateTime {
val zone = runCatching { ZoneId.of(preferences.text("timezone_effective").ifBlank { preferences.text("timezone") }) }
.getOrDefault(ZoneId.systemDefault())
return instant.atZone(zone)
}
fun NativeState.moduleVisible(module: String): Boolean = access(module) != "none" &&
listOf("disabled_modules", "hidden_modules").none { key ->
val values = preferences.optJSONArray(key) ?: JSONArray()
(0 until values.length()).any { values.optString(it) == module }
}
fun NativeState.dashboardTiles(): List<String> {
val config = preferences.rows("dashboard_widgets")
return (
if (preferences.isNull("dashboard_widgets")) {
pwaSupportedWidgets
} else {
config.filter { it.optBoolean("visible", true) }.map { it.text("id") }
}
)
.filter { it in pwaSupportedWidgets && moduleVisible(it) && widget(it) }.distinct()
}
fun pwaWidgetVisibility(preferences: JSONObject, visible: Set<String>, editable: Set<String> = pwaSupportedWidgets.toSet()): JSONArray {
val existing = preferences.rows("dashboard_widgets")
val rows = if (preferences.isNull("dashboard_widgets")) {
pwaSupportedWidgets.mapIndexed { index, id ->
JSONObject().put("id", id).put("visible", true).put("order", index).put("size", "1x1")
}
} else {
existing
}
val result = JSONArray(
rows.map { row ->
JSONObject(row.toString()).apply {
if (text("id") in editable && text("id") in pwaSupportedWidgets) put("visible", text("id") in visible)
}
},
)
val configured = rows.map { it.text("id") }.toSet()
pwaSupportedWidgets.filter { it in visible && it in editable && it !in configured }.forEach { id ->
result.put(JSONObject().put("id", id).put("visible", true).put("order", result.length()).put("size", "1x1"))
}
return result
}
fun NativeState.nativeTabs(): List<NativePage> {
val order = preferences.optJSONArray("mobile_nav_order")?.let { array ->
(0 until array.length()).map { array.optString(it) }
}.orEmpty()
val pages = (order + listOf("tasks", "kitchen", "notes")).mapNotNull {
when (it) {
"tasks" -> NativePage.TASKS
"shopping", "kitchen" -> NativePage.SHOPPING
"notes" -> NativePage.NOTES
else -> null // Unimplemented modules are never fake navigation targets.
}
}.distinct().filter { moduleVisible(it.name.lowercase()) }
return listOf(NativePage.HOME) + pages + NativePage.MORE
}
fun NativePage.navigationLabel() = if (this == NativePage.SHOPPING) "Küche" else title
@@ -0,0 +1,254 @@
package community.yuvomi.mobile
import android.os.Build
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
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.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun PwaBottomNavigation(
state: NativeState,
backdrop: GraphicsLayer,
contentOrigin: Offset,
onNavigate: (NativePage) -> Unit,
onCreate: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = LocalPwaPalette.current
var origin by remember { mutableStateOf(Offset.Zero) }
val canCreate = !state.busy && when (state.page) {
NativePage.HOME -> listOf("tasks", "notes", "shopping").any { state.access(it) == "write" }
else -> state.access(state.page.name.lowercase()) == "write"
}
Box(modifier.padding(horizontal = 8.dp, vertical = 8.dp).widthIn(max = 560.dp).fillMaxWidth()) {
Box(
Modifier.fillMaxWidth().shadow(
14.dp,
CircleShape,
ambientColor = Color.Black.copy(alpha = .15f),
spotColor = Color.Black.copy(alpha = .18f),
)
.clip(CircleShape).onGloballyPositioned { origin = it.positionInRoot() }
.border(1.dp, Color.White.copy(alpha = if (colors.dark) .12f else .65f), CircleShape),
) {
if (Build.VERSION.SDK_INT >= 31) {
Canvas(
Modifier.matchParentSize().graphicsLayer {
val blur = 24.dp.toPx()
renderEffect = BlurEffect(blur, blur, TileMode.Clamp)
},
) {
translate(contentOrigin.x - origin.x, contentOrigin.y - origin.y) { drawLayer(backdrop) }
}
}
val glass = if (colors.dark) Color(0xFF2D2A27) else Color(0xFFFBFAF7)
Box(
Modifier.matchParentSize().background(
glass.copy(
alpha = if (Build.VERSION.SDK_INT <
31
) {
1f
} else if (colors.dark) {
.50f
} else {
.66f
},
),
),
)
Box(
Modifier.matchParentSize().background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = if (colors.dark) .06f else .24f), Color.Transparent)),
),
)
Row(
Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(start = 8.dp, end = 8.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
state.nativeTabs().forEach { page ->
val active = state.page == page
Column(
Modifier.weight(1f).clickable(enabled = !state.busy, role = Role.Tab) { onNavigate(page) }
.semantics {
selected = active
contentDescription = page.navigationLabel()
}.testTag("nav-${page.name}"),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
Modifier.size(width = 48.dp, height = 40.dp).clip(CircleShape)
.background(if (active) colors.accent.copy(alpha = if (colors.dark) .24f else .16f) else Color.Transparent),
contentAlignment = Alignment.Center,
) { PwaIcon(page.pwaGlyph(), if (active) colors.accent else colors.module(page), selected = active) }
Spacer(Modifier.height(2.dp))
Text(
page.navigationLabel(),
style = PwaType.micro,
color = if (active) colors.accent else colors.text,
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium,
textAlign = TextAlign.Center,
maxLines = 1,
)
}
}
Spacer(Modifier.width(4.dp))
Box(
Modifier.size(48.dp).clip(CircleShape)
.background(Brush.verticalGradient(listOf(colors.accent.copy(alpha = .76f), colors.accent)))
.border(1.dp, Color.White.copy(alpha = .24f), CircleShape)
.clickable(enabled = canCreate, role = Role.Button, onClick = onCreate)
.semantics { contentDescription = "Hinzufügen" }.testTag("add-entry"),
contentAlignment = Alignment.Center,
) { PwaIcon(PwaGlyph.PLUS, if (canCreate) colors.vividInk else colors.tertiary, 24.dp) }
}
}
}
}
@Composable
fun PwaMenuRow(
icon: PwaGlyph,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
tint: Color = LocalPwaPalette.current.accent,
) {
val colors = LocalPwaPalette.current
Row(
modifier.fillMaxWidth().heightIn(
min = 52.dp,
).clickable(role = Role.Button, onClick = onClick).padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
PwaModuleSeal(icon, tint, 32.dp)
Text(label, Modifier.weight(1f), style = PwaType.body, color = colors.text)
PwaIcon(PwaGlyph.CHEVRON, colors.tertiary, 16.dp)
}
}
@Composable
fun NativeMore(state: NativeState, navigate: (NativePage) -> Unit, logout: () -> Unit, forget: () -> Unit) {
val colors = LocalPwaPalette.current
Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp).padding(bottom = 24.dp)) {
Text("Mehr", style = PwaType.section, color = colors.text, modifier = Modifier.padding(bottom = 16.dp))
PwaCard(Modifier.fillMaxWidth()) {
Column {
state.nativeTabs().filter { it != NativePage.HOME && it != NativePage.MORE }.forEachIndexed { index, page ->
if (index > 0) HorizontalDivider(color = colors.border, modifier = Modifier.padding(start = 56.dp))
PwaMenuRow(page.pwaGlyph(), page.title, { navigate(page) }, tint = colors.module(page))
}
}
}
Spacer(Modifier.height(20.dp))
Text("KONTO", style = PwaType.label, color = colors.secondary, modifier = Modifier.padding(start = 12.dp, bottom = 8.dp))
PwaCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(state.user?.text("display_name").orEmpty(), style = PwaType.headline, color = colors.text)
Text(state.server, style = PwaType.caption, color = colors.secondary)
PwaTextAction("Abmelden", logout, enabled = !state.busy)
PwaTextAction("Server und lokale Sitzung entfernen", forget, enabled = !state.busy, color = colors.danger)
}
}
Text(
"Weitere Module und Einstellungen sind noch nicht nativ verfügbar. Kein Browser-Fallback.",
style = PwaType.caption,
color = colors.tertiary,
modifier = Modifier.padding(12.dp),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NativeDashboardOptions(state: NativeState, dismiss: () -> Unit, save: (Set<String>) -> Unit) {
val colors = LocalPwaPalette.current
var visible by remember { mutableStateOf(state.dashboardTiles().toSet()) }
ModalBottomSheet(
onDismissRequest = dismiss,
containerColor = colors.raised,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
shape = RoundedCornerShape(topStart = 26.dp, topEnd = 26.dp),
) {
Column(Modifier.padding(16.dp)) {
Text("Übersicht anpassen", style = PwaType.section, color = colors.text)
Text(
"Für dein Konto · wird auch in der PWA gespeichert",
style = PwaType.caption,
color = colors.secondary,
modifier = Modifier.padding(vertical = 12.dp),
)
listOf("tasks" to "Aufgaben", "calendar" to "Kalender", "shopping" to "Einkauf", "notes" to "Notizen")
.filter { state.moduleVisible(it.first) && state.widget(it.first) }.forEach { (id, label) ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Checkbox(id in visible, { checked -> visible = if (checked) visible + id else visible - id })
Text(label, style = PwaType.body)
}
}
Text(
"Nur bereits nativ unterstützte Kacheln. Andere gespeicherte Kacheln werden nicht verändert.",
style = PwaType.caption,
color = colors.tertiary,
)
PwaButton(
"Speichern",
{ save(visible) },
Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 16.dp),
!state.busy && state.preferences.has("dashboard_widgets"),
)
}
}
}