SHA256
feat(android): align native preview with measured PWA design
This commit is contained in:
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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user