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
+2759 -716

No files matched your search

+1 -1
View File
@@ -98,7 +98,7 @@ jobs:
disable-animations: true
script: |
bash android/gradlew -p android connectedDebugAndroidTest
python3 scripts/verify-test-results.py android/app/build/outputs/androidTest-results/connected/debug --minimum 6
python3 scripts/verify-test-results.py android/app/build/outputs/androidTest-results/connected/debug --minimum 9
python3 scripts/test-integration.py "$YUVOMI_UPSTREAM" --android
python3 scripts/test-integration.py "$YUVOMI_UPSTREAM" --android --cookie-without-secure
- uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de
+12
View File
@@ -2,6 +2,18 @@
## [Unreleased]
### Android 0.2.2 native preview (code 4)
- Replace the initial Material-style slice with native PWA-derived typography,
light/dark colors, module icons/seals, compact cards, list rows, capsule
navigation and bottom-sheet editors. Remove the duplicate overview heading.
- Read personal dashboard/favorite preferences, select the first shopping list,
and save supported widget visibility without deleting unsupported config.
- Add real-PWA reference capture and a native ten-view light/dark screenshot
gallery with measured content viewport; browser automation is tooling only.
- Preserve the confirmed HTTPS login/session fix. This is an Android design pass,
**not accepted 1:1 parity**; iOS and remaining modules are unchanged/incomplete.
### Android 0.2.1 native preview (code 3)
- Fix login against upstream-default session cookies without `Secure`: Android
+1
View File
@@ -21,6 +21,7 @@ import a browser/WebView session; sign in again after upgrading the prototype.
- [Architecture](docs/architecture.md)
- [Native preview delivery report](docs/native-delivery-report.md)
- [PWA design comparison and remaining differences](docs/native-design.md)
- [Android build](docs/build-android.md) / [iOS build](docs/build-ios.md)
- [Compatibility and authentication](docs/server-compatibility.md)
- [Release process](docs/release-process.md)
+4
View File
@@ -6,6 +6,10 @@ Native design-token mappings follow upstream DESIGN.md/styles. The native
three-circle brand mark is redrawn from upstream's published brand geometry and
colors (`public/utils/brand-mark.js` and `docs/logo.svg`); its MIT attribution is
preserved below. No affiliation or trademark ownership is implied.
Android's `PwaIcons.kt` also adapts the module paths from upstream
`public/nav-icons.js`; the native palette, spacing and typography follow its
`public/styles/{tokens,typography,layout,dashboard,auth,tasks,notes,shopping}.css`.
The reference browser tooling is development-only and is not shipped in the apps.
Upstream Yuvomi: https://github.com/ulsklyc/yuvomi
+2 -2
View File
@@ -10,8 +10,8 @@ android {
applicationId = "community.yuvomi.mobile"
minSdk = 26
targetSdk = 37
versionCode = 3
versionName = "0.2.1-native-preview"
versionCode = 4
versionName = "0.2.2-native-preview"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
@@ -0,0 +1,59 @@
package community.yuvomi.mobile
import androidx.activity.compose.setContent
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.json.JSONObject
import org.junit.Rule
import org.junit.Test
class PwaComponentsTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
private fun page(state: NativeState) {
compose.activityRule.scenario.onActivity { activity ->
activity.setContent {
PwaTheme {
NativeCollection(state, {}, {}, {}, {}, {}, {}, {}, {})
}
}
}
}
@Test fun readOnlyTaskCannotBeToggledOrEdited() {
page(
NativeState(
page = NativePage.TASKS,
permissions = JSONObject("""{"modules":{"tasks":"read"}}"""),
rows = listOf(JSONObject("""{"id":1,"title":"Nur lesen","status":"open","category":"misc"}""")),
),
)
compose.onNodeWithContentDescription("Nur lesen").assertIsNotEnabled()
compose.onNodeWithContentDescription("Weitere Aktionen").assertDoesNotExist()
compose.onNodeWithText("SONSTIGES").assertIsDisplayed()
}
@Test fun emptyReadOnlyPageDoesNotAdvertiseCreation() {
page(NativeState(page = NativePage.NOTES, permissions = JSONObject("""{"modules":{"notes":"read"}}""")))
compose.onNodeWithText("Du hast hier Lesezugriff.").assertIsDisplayed()
compose.onNodeWithText("Neue Einträge erstellst du mit +.").assertDoesNotExist()
}
@Test fun taskFilterShowsDoneEntriesWithoutChangingServerState() {
page(
NativeState(
page = NativePage.TASKS,
rows = listOf(JSONObject("""{"id":1,"title":"Erledigte Aufgabe","status":"done","category":"misc"}""")),
),
)
compose.onNodeWithText("Erledigte Aufgabe").assertDoesNotExist()
compose.onNodeWithContentDescription("Filter").performClick()
compose.onNodeWithText("Erledigt").performClick()
compose.onNodeWithText("Erledigte Aufgabe").assertIsDisplayed()
}
}
@@ -1,7 +1,10 @@
package community.yuvomi.mobile
import android.app.UiModeManager
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.SystemClock
import android.view.WindowManager
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.test.assertIsDisplayed
@@ -11,7 +14,6 @@ import androidx.compose.ui.test.isEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
@@ -33,6 +35,9 @@ class NativeUpstreamTest {
val reset = object : ExternalResource() {
override fun before() {
val context = ApplicationProvider.getApplicationContext<Context>()
if (Build.VERSION.SDK_INT >= 31) {
context.getSystemService(UiModeManager::class.java).setApplicationNightMode(UiModeManager.MODE_NIGHT_NO)
}
context.getSharedPreferences("native-server", Context.MODE_PRIVATE).edit().clear().commit()
context.getSharedPreferences("native-session", Context.MODE_PRIVATE).edit().clear().commit()
if (android.os.Build.VERSION.SDK_INT >= 37) {
@@ -72,10 +77,31 @@ class NativeUpstreamTest {
org.json.JSONObject().put("name", "Wochenmarkt"),
).getJSONObject("data").getLong("id")
seed.request("/shopping/$seedList/items", "POST", org.json.JSONObject().put("name", "Äpfel").put("quantity", "4"))
seed.request(
"/preferences",
"PUT",
org.json.JSONObject()
.put(
"dashboard_widgets",
org.json.JSONArray(
listOf("tasks", "calendar", "shopping", "notes").mapIndexed { order, id ->
org.json.JSONObject().put("id", id).put("visible", true).put("order", order).put("size", "1x1")
},
),
)
.put("mobile_nav_order", org.json.JSONArray(listOf("tasks", "kitchen", "notes")))
.put("timezone", "Europe/Berlin"),
)
seed.request("/auth/logout", "POST")
compose.onNodeWithTag("server-url").performTextInput(origin)
compose.onNodeWithTag("connect").performClick()
awaitTag("username")
captureFixtureScreen("login-light.png")
nightMode(true)
awaitTag("username")
captureFixtureScreen("login-dark.png")
nightMode(false)
awaitTag("username")
compose.onNodeWithTag("username").performTextInput("mobiletest")
compose.onNodeWithTag("password").performTextInput(password + "-invalid")
compose.onNodeWithTag("login").performScrollTo().performClick()
@@ -87,15 +113,23 @@ class NativeUpstreamTest {
compose.activityRule.scenario.onActivity {
assertEquals(null, ViewModelProvider(it)[NativeController::class.java].state.error)
}
captureFixtureDashboard()
captureFixtureScreen("native-dashboard.png")
captureFixtureScreen("dashboard-light.png")
captureModuleGallery("light")
nightMode(true)
awaitTag("native-dashboard")
captureFixtureScreen("dashboard-dark.png")
captureModuleGallery("dark")
nightMode(false)
awaitTag("native-dashboard")
compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.activityRule.scenario.recreate()
awaitTag("native-dashboard")
compose.onNodeWithTag("nav-TASKS").performClick()
awaitText("Hinzufügen")
compose.onNodeWithText("Hinzufügen").performClick()
awaitTag("add-entry")
compose.onNodeWithTag("add-entry").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native task regression")
compose.onNodeWithTag("entry-detail").performTextInput("Created with native controls")
compose.onNodeWithTag("entry-save").performClick()
@@ -103,8 +137,8 @@ class NativeUpstreamTest {
assertTrue(readApi(origin).request("/tasks").rows().any { it.text("title") == "Native task regression" })
compose.onNodeWithTag("nav-NOTES").performClick()
awaitText("Hinzufügen")
compose.onNodeWithText("Hinzufügen").performClick()
awaitTag("add-entry")
compose.onNodeWithTag("add-entry").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native note regression")
compose.onNodeWithTag("entry-detail").performTextInput("Not an embedded web page")
compose.onNodeWithTag("entry-save").performClick()
@@ -112,14 +146,14 @@ class NativeUpstreamTest {
assertTrue(readApi(origin).request("/notes").rows().any { it.text("content") == "Not an embedded web page" })
compose.onNodeWithTag("nav-SHOPPING").performClick()
awaitText("Neue Liste")
compose.onNodeWithText("Neue Liste").performClick()
awaitTag("add-list")
compose.onNodeWithTag("add-list").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native shopping regression")
compose.onNodeWithTag("entry-save").performClick()
awaitText("Native shopping regression")
compose.onNodeWithText("Native shopping regression").performClick()
awaitText("Hinzufügen")
compose.onNodeWithText("Hinzufügen").performClick()
awaitTag("add-entry")
compose.onNodeWithTag("add-entry").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native apples")
compose.onNodeWithTag("entry-detail").performTextInput("3")
compose.onNodeWithTag("entry-save").performClick()
@@ -157,15 +191,67 @@ class NativeUpstreamTest {
return api
}
private fun captureFixtureDashboard() {
private fun nightMode(dark: Boolean) {
check(Build.VERSION.SDK_INT >= 31) { "The reference gallery requires per-app night mode (API 31+)" }
compose.activityRule.scenario.onActivity {
it.getSystemService(UiModeManager::class.java).setApplicationNightMode(
if (dark) UiModeManager.MODE_NIGHT_YES else UiModeManager.MODE_NIGHT_NO,
)
}
compose.waitUntil(10_000) {
var matches = false
compose.activityRule.scenario.onActivity {
matches = (
it.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK ==
android.content.res.Configuration.UI_MODE_NIGHT_YES
) == dark
}
matches
}
compose.waitForIdle()
}
private fun captureModuleGallery(theme: String) {
listOf(NativePage.TASKS, NativePage.NOTES, NativePage.SHOPPING).forEach { page ->
compose.onNodeWithTag("nav-${page.name}").performClick()
compose.waitUntil(30_000) {
var ready = false
compose.activityRule.scenario.onActivity {
val state = ViewModelProvider(it)[NativeController::class.java].state
ready = !state.busy && state.page == page && state.rows.isNotEmpty()
}
ready
}
captureFixtureScreen("${page.name.lowercase()}-$theme.png")
}
compose.onNodeWithTag("nav-HOME").performClick()
awaitTag("native-dashboard")
}
private fun captureFixtureScreen(name: String) {
// Only this temporary-fixture test clears the screenshot restriction.
// Ordinary builds always retain FLAG_SECURE, including native login.
compose.activityRule.scenario.onActivity { it.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }
compose.activityRule.scenario.onActivity {
it.currentFocus?.clearFocus()
it.window.insetsController?.hide(android.view.WindowInsets.Type.ime())
it.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
try {
compose.waitForIdle()
compose.waitUntil(10_000) {
var hidden = false
compose.activityRule.scenario.onActivity {
hidden = it.window.decorView.rootWindowInsets?.isVisible(android.view.WindowInsets.Type.ime()) != true
}
hidden
}
// PixelCopy must run after the real window/inset animation, not only Compose idleness.
SystemClock.sleep(600)
// Capture the native window through PixelCopy, not a potentially stale
// secure compositor frame returned by UiAutomation immediately after clearing.
val image = compose.onRoot().captureToImage().asAndroidBitmap()
val node = compose.onNodeWithTag("native-screen")
val bounds = node.fetchSemanticsNode().boundsInRoot
val image = node.captureToImage().asAndroidBitmap()
try {
val first = image.getPixel(0, 0)
assertTrue(
@@ -175,9 +261,20 @@ class NativeUpstreamTest {
},
)
// AGP collects this test output before uninstalling the fixture APK.
PlatformTestStorageRegistry.getInstance().openOutputFile("native-dashboard.png").use {
PlatformTestStorageRegistry.getInstance().openOutputFile(name).use {
check(image.compress(Bitmap.CompressFormat.PNG, 100, it))
}
if (name == "native-dashboard.png") {
val resources = compose.activity.resources
val metadata = org.json.JSONObject()
.put("widthPx", image.width).put("heightPx", image.height)
.put("density", resources.displayMetrics.density).put("fontScale", resources.configuration.fontScale)
.put("sdk", Build.VERSION.SDK_INT)
.put("boundsInRoot", org.json.JSONArray(listOf(bounds.left, bounds.top, bounds.right, bounds.bottom)))
PlatformTestStorageRegistry.getInstance().openOutputFile("viewport.json").use {
it.write(metadata.toString(2).toByteArray())
}
}
} finally {
image.recycle()
}
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"),
)
}
}
}
@@ -0,0 +1,101 @@
package community.yuvomi.mobile
import java.time.Instant
import java.time.LocalDate
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PwaPresentationTest {
@Test fun greetingUsesFirstNameAndUpstreamDayBoundaries() {
listOf(0 to "Abend", 4 to "Abend", 5 to "Morgen", 11 to "Morgen", 12 to "Tag", 17 to "Tag", 18 to "Abend", 23 to "Abend")
.forEach { (hour, period) -> assertEquals("Guten $period, Mobile", pwaGreeting(" Mobile Test ", hour)) }
assertEquals("Guten Tag", pwaGreeting(" ", 12))
}
@Test fun dueLabelsHandleRelativeDatesAndMalformedServerValues() {
val today = LocalDate.of(2026, 10, 21)
assertEquals("Heute fällig", pwaDueLabel("2026-10-21", today))
assertEquals("Morgen fällig", pwaDueLabel("2026-10-22", today))
assertEquals("Gestern fällig", pwaDueLabel("2026-10-20", today))
assertEquals("", pwaDueLabel("", today))
assertEquals("invalid", pwaDueLabel("invalid", today))
assertTrue(pwaDueLabel("2026-10-25", today).startsWith("25."))
}
@Test fun householdClockUsesEffectiveZoneAcrossMidnightAndDst() {
val state = NativeState(preferences = JSONObject().put("timezone", "UTC").put("timezone_effective", "Europe/Berlin"))
assertEquals(23, state.householdNow(Instant.parse("2026-10-21T21:00:00Z")).hour)
assertEquals(22, state.householdNow(Instant.parse("2026-10-26T21:00:00Z")).hour)
assertEquals(LocalDate.of(2026, 10, 22), state.householdNow(Instant.parse("2026-10-21T23:00:00Z")).toLocalDate())
assertNotNull(state.copy(preferences = JSONObject().put("timezone", "invalid")).householdNow())
}
@Test fun widgetOrderVisibilityAndPermissionFilteringArePreserved() {
val prefs =
JSONObject(
"""{"dashboard_widgets":[{"id":"notes"},{"id":"weather"},{"id":"tasks"},
{"id":"shopping","visible":false},{"id":"calendar"},{"id":"notes"}]}""",
)
val state = NativeState(preferences = prefs, permissions = JSONObject("""{"modules":{"tasks":"none"}}"""))
assertEquals(listOf("notes", "calendar"), state.dashboardTiles())
assertEquals(emptyList<String>(), state.copy(preferences = JSONObject().put("dashboard_widgets", JSONArray())).dashboardTiles())
assertEquals(pwaSupportedWidgets, NativeState().dashboardTiles())
}
@Test fun disabledHiddenAndWidgetDeniedModulesAreNotShown() {
val state = NativeState(
preferences = JSONObject("""{"disabled_modules":["tasks"],"hidden_modules":["notes"]}"""),
permissions = JSONObject("""{"widgets":{"calendar":"none"}}"""),
)
assertEquals(listOf("shopping"), state.dashboardTiles())
assertEquals(listOf(NativePage.HOME, NativePage.SHOPPING, NativePage.MORE), state.nativeTabs())
}
@Test fun navigationHonorsSupportedFavoriteOrderWithoutFakeDestinations() {
val state =
NativeState(preferences = JSONObject().put("mobile_nav_order", JSONArray(listOf("weather", "notes", "notes", "kitchen"))))
assertEquals(listOf(NativePage.HOME, NativePage.NOTES, NativePage.SHOPPING, NativePage.TASKS, NativePage.MORE), state.nativeTabs())
}
@Test fun visibilityEditsRetainUnsupportedEntriesOptionsAndInput() {
val prefs =
JSONObject(
"""{"dashboard_widgets":[{"id":"weather","visible":true,"options":{"city":"Berlin"}},
{"id":"notes","size":"2x1","options":{"category_id":4}},{"id":"tasks","visible":false}]}""",
)
val original = prefs.toString()
val result = pwaWidgetVisibility(prefs, setOf("tasks"))
assertEquals(original, prefs.toString())
assertEquals(prefs.getJSONArray("dashboard_widgets").getJSONObject(0).toString(), result.getJSONObject(0).toString())
assertFalse(result.getJSONObject(1).getBoolean("visible"))
assertEquals("2x1", result.getJSONObject(1).getString("size"))
assertEquals(4, result.getJSONObject(1).getJSONObject("options").getInt("category_id"))
assertTrue(result.getJSONObject(2).getBoolean("visible"))
assertEquals(0, pwaWidgetVisibility(JSONObject().put("dashboard_widgets", JSONArray()), emptySet()).length())
}
@Test fun enablingAnUnconfiguredSupportedWidgetAddsOnlyThatEntry() {
val result = pwaWidgetVisibility(JSONObject().put("dashboard_widgets", JSONArray()), setOf("tasks", "unknown"))
assertEquals(1, result.length())
assertEquals("tasks", result.getJSONObject(0).getString("id"))
assertTrue(result.getJSONObject(0).getBoolean("visible"))
}
@Test fun restrictedSupportedWidgetConfigRemainsUnchanged() {
val prefs = JSONObject("""{"dashboard_widgets":[{"id":"tasks","visible":true}]}""")
val result = pwaWidgetVisibility(prefs, emptySet(), setOf("notes"))
assertTrue(result.getJSONObject(0).getBoolean("visible"))
}
@Test fun labelsDoNotExposeRawInternalCategoriesOrPriorities() {
assertEquals("Sonstiges", pwaGroup("general"))
assertEquals("Gemüse", pwaGroup("Gemüse"))
assertEquals("Hoch", pwaPriority("high"))
assertEquals("", pwaPriority("unknown"))
}
}
+33
View File
@@ -13,6 +13,39 @@ Read [native parity](native-parity.md), [architecture](architecture.md), and
shopping slices exist, but **no module has accepted full visual/functional parity**.
The remaining pages/settings/auth/file/offline/notification work is explicit.
### Android design follow-up: 0.2.2 native preview / code 4
The user confirmed the login fix and requested the PWA design 1:1. The Android
slice now uses PWA-derived native primitives in `PwaDesign.kt`, `PwaIcons.kt`,
`PwaShell.kt`, `NativePages.kt` and `NativeForms.kt`, instead of the first generic
Material layout. `PwaPresentation.kt` handles greeting/timezone/config filtering;
the controller reads personal preferences and auto-selects the first shopping list.
The confirmed cookie/session transport was not changed.
Read [design comparison](native-design.md) before making visual claims. External
Playwright 1.55.1/Chromium 140 tooling captures the actual unmodified PWA; the
emulator exports ten light/dark native views with viewport metadata. Match the
360 × 592 content viewport and Android variable Roboto, not the system-bar-inclusive
root. The earlier clipped tab labels were caused by per-tab circular clipping.
The apparent second white capsule segment occurred where subsequent dashboard
content remained visible beneath the translucent navigation; inspect actual
matched captures rather than treating any visible card edge as a second bar.
Reference generation uses `/tmp/opencode/yuvomi-visual-tools/node_modules/playwright`,
`/tmp/opencode/yuvomi-visual-tools/fonts.conf` and persistent browsers at
`/home/neste/yuvomi-tools/playwright-browsers`. The temporary font config points to
the emulator's variable `/system/fonts/Roboto-Regular.ttf`; it includes all weights.
Set `PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64` on this Ubuntu 26 host.
Reference output is ignored under `artifacts/visual/pwa-360x592`; native screenshots
are in the external build's `app/outputs/native-screenshots`. The side-by-side
compositor is tooling only; screenshots are evidence, not automated parity approval.
iOS is unchanged. No module has accepted full functional/visual parity, the rest
of the inventory remains open, and signing stays paused. Continue refinement from
actual comparison images, including non-default font sizes/devices and native
module functionality; do not declare the whole user request finished from this
Android slice alone.
### Android login follow-up: 0.2.1 native preview / code 3
A user reported immediate HTTP-401 login failure although the same direct password
Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

+91
View File
@@ -0,0 +1,91 @@
# Native PWA design comparison
The target is the actual unmodified Yuvomi 2.78.0 PWA, revision
`10bfef7686ed7303b4c74c7fcc55a5eb16356c9d`, not a new Material reinterpretation.
Android 0.2.2/code 4 is a design pass on the existing native slice. **No page has
accepted 1:1 visual/functional parity.** iOS has not received this Android pass.
## Implementation
`PwaDesign.kt` maps measured palette/type roles, field shapes and module seals.
`PwaIcons.kt` draws upstream module geometry on the native canvas.
`PwaShell.kt` provides the capsule/FAB and native sheets; API 31+ uses an in-memory
recorded native drawing layer for the blurred backdrop, with opaque older-API
fallback. No HTML, JavaScript or browser surface is shipped.
`NativePages.kt` / `NativeForms.kt` compose the supported native screens.
Personal preferences and shopping lists are loaded from the real API. Widget
visibility writes retain other stored widget entries/options; they are personal,
not household default edits. Entries hidden/denied to this native UI are retained;
new supported selections can be added. Saving is disabled if preference loading
did not provide a known configuration, rather than overwriting unknown data.
## Reproducible comparison
The fixture has synthetic account/data only: one task, pinned note, list/item,
four widgets (`tasks`, `calendar`, `shopping`, `notes`) and three supported mobile
favorites (`tasks`, `kitchen`, `notes`). This deliberate **supported-slice** setup
is not the PWA's complete/default dashboard or a full navigation comparison.
Native integration exports `login`, `dashboard`, `tasks`, `notes`, `shopping`, each
in light/dark, plus the compatible `native-dashboard.png` and `viewport.json`.
Capture clears `FLAG_SECURE` only in that temporary-fixture test and restores it in
`finally`. It waits for IME/window settling and crops `native-screen`, excluding
system bars. Ordinary builds remain screenshot-protected.
The collector requires the whole allow-listed gallery from exactly one device,
rejects missing/invalid/ambiguous output and removes only known stale evidence.
Observed API-35 content viewport: **720 × 1184 px, density 2, fontScale 1**, hence
360 × 592 CSS pixels for the browser reference. Root bounds are
`[0,48,720,1232]` on the 720 × 1280 emulator. Do not compare a full native root
image against a 360 × 640 PWA content viewport.
```sh
ANDROID_SERIAL=emulator-5554 python3 scripts/test-integration.py /path/to/pinned-upstream --android --cookie-without-secure
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 \
PLAYWRIGHT_BROWSERS_PATH=/path/to/external/browsers \
FONTCONFIG_FILE=/path/to/isolated/android-roboto/fonts.conf \
python3 scripts/capture-pwa-reference.py /path/to/pinned-upstream \
--playwright-module /path/to/external/node_modules/playwright \
--width 360 --height 592 --output artifacts/visual/pwa-360x592
node scripts/compare-native-pwa.mjs /path/to/external/node_modules/playwright \
/path/to/build/app/outputs/native-screenshots artifacts/visual/pwa-360x592 \
artifacts/visual/comparisons
```
Playwright/Chromium and the emulator's variable Roboto are external tooling, not
application dependencies. The reference recorder captures real browser PNGs and
computed styles, without editing upstream files. It uses a temporary loopback-only
HTTPS fixture, blocks external browser requests and does not export credentials,
cookies, generated certificates or keys. Dates/greetings and initial account colors
can vary; compare those separately from geometry. Native integration keeps OIDC
enabled for the redirect-refusal regression, whereas the password-login reference
disables it; their extra SSO controls are not a valid exact login comparison.
The comparison compositor refuses mismatched viewport/image sizes or font scale,
places the actual captures side by side and records their SHA-256 hashes. It does
not replace either screenshot, normalize away UI differences or assert acceptance.
## Explicit remaining differences
- Full calendar navigation and its widget action are absent, not fake buttons.
- Native SSO/recovery/invites and most modules/settings remain unimplemented.
- Advanced task categories/assignment/statuses, note rendering/categories and
shopping quick-add/details do not yet match all PWA interactions.
- Card internals, utility icons, shadows/glass and form geometry still require
visual refinement and broader device/font-size/accessibility testing.
- The three supported favorites stand in for unavailable native destinations;
this is not full preference/navigation parity.
- Apple UI/build validation remains blocked as recorded in [validation](validation.md).
Screenshot existence and passing CRUD tests are evidence, **not visual acceptance**.
See [native parity](native-parity.md) for the complete scope.
## Reviewed examples from this pass
Left is the real PWA, right the actual native capture. Remaining differences are
deliberately visible, including the absent calendar action, widget spacing,
utility icon/control differences and upstream's dashboard-customization hint.
- [Dashboard, light](images/dashboard-light-comparison.png)
- [Dashboard, dark](images/dashboard-dark-comparison.png)
- [Tasks, light](images/tasks-light-comparison.png)
- [Notes, dark](images/notes-dark-comparison.png)
+4
View File
@@ -19,6 +19,10 @@ Use these states honestly:
- **Parity accepted**: requires functional and visual evidence on both platforms.
**No module currently has this status.**
Android 0.2.2 has a PWA-derived design pass on the supported slice, with real
reference/native light/dark captures. See [design comparison](native-design.md).
This does not change the acceptance status or certify the unchanged iOS UI.
## Page inventory
| Upstream page | Native status | Work still required before parity |
+29
View File
@@ -8,6 +8,35 @@ tests are in `androidTestNative` / `androidTestNativeIntegration`, JVM native po
tests, and Swift production contracts/transport tests. Archived WebView tests are
not included in the native suites or counted toward native completion.
## Android 0.2.2 / code 4: PWA-derived design pass
Local API-35 validation after the design pass:
| Check | Observed result |
| --- | --- |
| JVM policy/presentation tests | 23 passed, zero failures/errors/skips |
| Native Compose UI/security/presentation tests | 9 passed, zero failures/errors/skips |
| Real upstream, default non-Secure cookie over HTTPS | 10 passed, zero failures/errors/skips |
| Real upstream, Secure cookie | 10 passed, zero failures/errors/skips |
| Android lint and packages | Both lint variants; debug and unsigned release APK/AAB passed |
| Screenshot evidence | Ten light/dark native views, viewport metadata, ten actual-PWA references and ten matched-size side-by-side images |
| Script checks | 9 screenshot-collection, 8 JUnit-verifier and 14 runner/probe regressions passed; repository/Python/Node/workflow checks passed |
| Dependency scan | OSV listed no advisories for 266 locked/direct Maven coordinates/plugins |
The first ordinary device-test attempt ran **zero tests** because a leftover
CI-signed 0.2.1 preview had a different debug certificate. The verifier rejected
that run despite Gradle's successful exit. AGP cleanup removed the package; the
clean rerun executed all nine tests. Both complete integration harnesses then
exited successfully and retained the rejected-password/successful-retry regression.
Logs: `/home/neste/yuvomi-design-build-final.log`,
`/home/neste/yuvomi-design-cookie-final.log`,
`/home/neste/yuvomi-design-secure-final.log`.
The [design comparison](native-design.md) includes reviewed synthetic images and
documents the exact 360 × 592 content viewport and intentional fixture scope.
**This is not accepted 1:1 parity.** iOS was not changed or newly validated.
Remote KVM/CoreSimulator prerequisites and production signing remain open.
## Android 0.2.1 / code 3: reproduced login regression
The user reported immediate login failure despite a working direct browser login.
+105
View File
@@ -0,0 +1,105 @@
// Browser automation is a reference-measurement tool, never an application renderer.
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
const [playwrightModule, destination] = process.argv.slice(2);
const require = createRequire(import.meta.url);
const { chromium } = require(playwrightModule);
const origin = process.env.YUVOMI_TEST_URL;
if (!/^https:\/\/127\.0\.0\.1:\d+$/.test(origin)) throw new Error('Only the temporary loopback fixture is allowed');
const browser = await chromium.launch({ headless: true, args: process.getuid?.() === 0 ? ['--no-sandbox'] : [] });
const width = Number(process.env.YUVOMI_VISUAL_WIDTH || 360);
const height = Number(process.env.YUVOMI_VISUAL_HEIGHT || 640);
const measurements = {
upstreamRevision: '10bfef7686ed7303b4c74c7fcc55a5eb16356c9d', browser: browser.version(),
androidFontConfiguration: Boolean(process.env.FONTCONFIG_FILE),
fixture: { widgets: ['tasks', 'calendar', 'shopping', 'notes'], mobileFavorites: ['tasks', 'kitchen', 'notes'] }, screens: [],
};
try {
const context = await browser.newContext({
viewport: { width, height }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, reducedMotion: 'reduce',
locale: 'de-DE', timezoneId: 'Europe/Berlin', ignoreHTTPSErrors: true,
userAgent: 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36',
serviceWorkers: 'block',
});
await context.route('**/*', route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort());
const api = context.request;
const password = process.env.YUVOMI_TEST_PASSWORD;
let csrf = '';
async function request(route, data) {
const response = await api.fetch(origin + '/api/v1' + route, {
method: data === undefined ? 'GET' : (route === '/preferences' ? 'PUT' : 'POST'),
data, headers: { 'X-CSRF-Token': csrf },
});
if (!response.ok()) throw new Error(`Fixture API failed at ${route}: HTTP ${response.status()}`);
const result = await response.json();
if (result.csrfToken) csrf = result.csrfToken;
return result;
}
await request('/auth/setup', { username: 'mobiletest', display_name: 'Mobile Test', password });
await request('/auth/login', { username: 'mobiletest', password });
await request('/auth/onboarding-seen', {});
await request('/auth/changelog-seen', {});
const today = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date());
await request('/tasks', { title: 'Gemeinsam den Alltag planen', priority: 'high', due_date: today });
await request('/notes', { title: 'Willkommen zuhause', content: 'Native Oberfläche, eigener Server.', pinned: 1 });
const list = await request('/shopping', { name: 'Wochenmarkt' });
await request(`/shopping/${list.data.id}/items`, { name: 'Äpfel', quantity: '4' });
// Match the implemented native slice, not a fabricated full-module acceptance.
await request('/preferences', {
dashboard_widgets: ['tasks', 'calendar', 'shopping', 'notes'].map((id, order) => ({ id, visible: true, order, size: '1x1' })),
dashboard_today_glance: false, mobile_nav_order: ['tasks', 'kitchen', 'notes'],
timezone: 'Europe/Berlin', timezone_hint_dismissed: true,
});
await request('/auth/logout', {});
const page = await context.newPage();
await page.addInitScript(() => {
localStorage.setItem('yuvomi-language', 'de');
localStorage.setItem('yuvomi-lang', 'de');
});
const selectors = ['.auth-page', '.auth-hero', '.auth-hero__mark', '.auth-hero__title', '.auth-hero__tagline', '.label', '.input', '.btn--primary', '.dashboard-overview__date',
'.dashboard-overview__title', '.widget', '.widget__header', '.widget__title', '.widget__body', '.nav-bottom__items',
'.nav-bottom .nav-item', '.page-toolbar', '.page-toolbar__title', '.task-card', '.task-card__title', '.note-card', '.list-row'];
async function capture(name) {
await page.evaluate(() => document.fonts.ready);
// Compare neutral states, not a keyboard focus ring left by fixture navigation.
await page.evaluate(() => document.activeElement?.blur?.());
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await page.screenshot({ path: path.join(destination, `${name}.png`), animations: 'disabled' });
measurements.screens.push({ name, viewport: page.viewportSize(), elements: await page.evaluate(selectors =>
selectors.flatMap(selector => [...document.querySelectorAll(selector)].slice(0, 5).map(element => {
const bounds = element.getBoundingClientRect(); const style = getComputedStyle(element);
return { selector, text: element.matches('input') ? '' : element.textContent.trim().slice(0, 80),
x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height,
font: style.fontFamily, fontSize: style.fontSize, fontWeight: style.fontWeight, lineHeight: style.lineHeight,
letterSpacing: style.letterSpacing, color: style.color, background: style.backgroundColor,
radius: style.borderRadius, padding: style.padding };
})), selectors) });
}
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce' });
await page.goto(origin + '/login');
await page.evaluate(value => localStorage.setItem('yuvomi-theme', value), theme);
await page.reload();
await page.locator('#auth-btn').waitFor();
await capture(`login-${theme}-${width}`);
await page.locator('#username').fill('mobiletest');
await page.locator('#password').fill(password);
await page.locator('#auth-btn').click();
await page.locator('.dashboard-overview__title').waitFor();
await page.locator('.widget--tasks .widget__body').waitFor();
await capture(`dashboard-${theme}-${width}`);
for (const [route, selector, name] of [['/tasks', '.task-card', 'tasks'], ['/notes', '.note-card', 'notes'], ['/shopping', '.shopping-item', 'shopping']]) {
await page.goto(origin + route);
await page.locator(selector).first().waitFor();
await capture(`${name}-${theme}-${width}`);
}
await request('/auth/me');
await request('/auth/logout', {});
}
await context.close();
} finally {
fs.writeFileSync(path.join(destination, 'measurements.json'), JSON.stringify(measurements, null, 2) + '\n');
await browser.close();
}
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Capture the unmodified pinned PWA with synthetic data, never a user's server."""
import argparse
import json
import os
from pathlib import Path
import secrets
import subprocess
import tempfile
import time
ROOT = Path(__file__).resolve().parents[1]
REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d"
def run(upstream, playwright_module, destination, width=360, height=640):
revision = subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip()
assert revision == REVISION, "Visual reference must use pinned unmodified upstream"
assert not subprocess.check_output(["git", "-C", str(upstream), "diff", "HEAD", "--name-only"], text=True).strip()
destination.mkdir(parents=True, exist_ok=True)
base = "/tmp/opencode" if Path("/tmp/opencode").exists() else None
with tempfile.TemporaryDirectory(prefix="yuvomi-visual-", dir=base) as temporary:
directory = Path(temporary)
subprocess.run([
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
"-keyout", str(directory / "key.pem"), "-out", str(directory / "cert.pem"),
"-subj", "/CN=Yuvomi visual fixture",
"-addext", "subjectAltName=IP:127.0.0.1,DNS:localhost",
], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
env = {**os.environ, "NODE_EXTRA_CA_CERTS": str(directory / "cert.pem"),
"YUVOMI_TEST_DISABLE_OIDC": "true", "YUVOMI_TEST_PASSWORD": secrets.token_urlsafe(24),
"YUVOMI_VISUAL_WIDTH": str(width), "YUVOMI_VISUAL_HEIGHT": str(height)}
with (directory / "server.log").open("w") as log:
server = subprocess.Popen(["node", str(ROOT / "scripts/integration-server.mjs"), str(upstream), temporary],
cwd=upstream, env=env, stdout=log, stderr=log)
try:
for _ in range(200):
if (directory / "ready.json").exists():
break
if server.poll() is not None:
raise RuntimeError("Visual fixture failed to start; no private logs exported")
time.sleep(0.1)
ready = json.loads((directory / "ready.json").read_text())
env["YUVOMI_TEST_URL"] = f"https://127.0.0.1:{ready['port']}"
subprocess.run(["node", str(ROOT / "scripts/capture-pwa-reference.mjs"),
str(playwright_module), str(destination)], env=env, check=True)
finally:
server.terminate()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
server.wait()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("upstream", type=Path)
parser.add_argument("--playwright-module", type=Path, required=True)
parser.add_argument("--output", type=Path, default=ROOT / "artifacts/visual/pwa")
parser.add_argument("--width", type=int, default=360)
parser.add_argument("--height", type=int, default=640)
args = parser.parse_args()
assert 320 <= args.width <= 1600 and 320 <= args.height <= 1800, "Invalid reference viewport"
run(args.upstream.resolve(), args.playwright_module.resolve(), args.output.resolve(), args.width, args.height)
+53
View File
@@ -0,0 +1,53 @@
// Development-only side-by-side evidence. This deliberately asserts no pixel parity.
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { createHash } from 'node:crypto';
const [modulePath, nativeDirectory, pwaDirectory, destination] = process.argv.slice(2);
if (!destination) throw new Error('Usage: node compare-native-pwa.mjs PLAYWRIGHT_MODULE NATIVE_DIR PWA_DIR OUTPUT_DIR');
const { chromium } = createRequire(import.meta.url)(path.resolve(modulePath));
const viewport = JSON.parse(fs.readFileSync(path.join(nativeDirectory, 'viewport.json')));
if (viewport.fontScale !== 1) throw new Error('This CSS reference comparison requires fontScale 1');
const cssWidth = viewport.widthPx / viewport.density;
const cssHeight = viewport.heightPx / viewport.density;
const reference = JSON.parse(fs.readFileSync(path.join(pwaDirectory, 'measurements.json')));
if (!reference.screens.every(screen => screen.viewport.width === cssWidth && screen.viewport.height === cssHeight)) {
throw new Error('PWA and native content viewports do not match');
}
function image(directory, name) {
const bytes = fs.readFileSync(path.join(directory, name));
if (bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' ||
bytes.readUInt32BE(16) !== viewport.widthPx || bytes.readUInt32BE(20) !== viewport.heightPx) {
throw new Error(`Invalid or mismatched PNG: ${name}`);
}
return { uri: `data:image/png;base64,${bytes.toString('base64')}`, sha256: createHash('sha256').update(bytes).digest('hex') };
}
fs.mkdirSync(destination, { recursive: true });
const browser = await chromium.launch({ headless: true, args: process.getuid?.() === 0 ? ['--no-sandbox'] : [] });
const records = [];
try {
const page = await browser.newPage({ viewport: { width: viewport.widthPx * 2, height: viewport.heightPx + 48 }, deviceScaleFactor: 1 });
await page.route('**/*', route => route.abort());
for (const name of ['login', 'dashboard', 'tasks', 'notes', 'shopping']) {
for (const theme of ['light', 'dark']) {
const native = image(nativeDirectory, `${name}-${theme}.png`);
const pwa = image(pwaDirectory, `${name}-${theme}-${cssWidth}.png`);
await page.setContent(`<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'">
<style>body{margin:0;background:#eee;font:20px sans-serif}main{display:flex}section{width:${viewport.widthPx}px}
header{height:48px;display:grid;place-items:center}img{display:block;width:100%}</style>
<main><section><header>PWA 2.78.0 · ${name} · ${theme}</header><img src="${pwa.uri}"></section>
<section><header>Android nativ · ${name} · ${theme} · keine 1:1-Abnahme</header><img src="${native.uri}"></section></main>`);
await page.locator('img').evaluateAll(images => Promise.all(images.map(image => image.decode())));
const file = `${name}-${theme}-comparison.png`;
await page.screenshot({ path: path.join(destination, file) });
records.push({ file, nativeSha256: native.sha256, pwaSha256: pwa.sha256 });
}
}
} finally {
await browser.close();
}
fs.writeFileSync(path.join(destination, 'comparison.json'), JSON.stringify({
status: 'Side-by-side evidence only; no visual acceptance', viewport, upstreamRevision: reference.upstreamRevision, records,
}, null, 2) + '\n');
console.log(`Wrote ${records.length} matched-viewport comparisons; no parity assertion.`);
+11 -3
View File
@@ -21,10 +21,16 @@ process.env.DB_ENCRYPTION_KEY = '';
process.env.NODE_ENV = 'test';
process.env.LOG_LEVEL = 'warn';
const tls = { key: fs.readFileSync(path.join(directory, 'key.pem')), cert: fs.readFileSync(path.join(directory, 'cert.pem')) };
const idp = await startIdentityProvider(tls);
const idp = process.env.YUVOMI_TEST_DISABLE_OIDC === 'true' ? null : await startIdentityProvider(tls);
if (idp) {
process.env.OIDC_ISSUER = idp.issuer;
process.env.OIDC_CLIENT_ID = idp.clientId;
process.env.OIDC_CLIENT_SECRET = idp.secret;
} else {
delete process.env.OIDC_ISSUER;
delete process.env.OIDC_CLIENT_ID;
delete process.env.OIDC_CLIENT_SECRET;
}
const { server } = await import(pathToFileURL(path.join(upstream, 'server/index.js')));
if (!server.listening) await once(server, 'listening');
let networkAvailable = true;
@@ -50,7 +56,9 @@ const proxy = https.createServer(tls, (request, response) => {
});
proxy.listen(0, '127.0.0.1');
await once(proxy, 'listening');
if (idp) {
process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`;
idp.setRedirect(process.env.OIDC_REDIRECT_URI);
fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp.port }));
process.on('SIGTERM', () => { idp.close(); proxy.close(); server.close(); process.exit(0); });
}
fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp?.port ?? null }));
process.on('SIGTERM', () => { idp?.close(); proxy.close(); server.close(); process.exit(0); });
+37
View File
@@ -2,6 +2,7 @@
"""Regressions for AGP screenshot output collection, not visual parity tests."""
import base64
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
@@ -57,6 +58,42 @@ class ScreenshotEvidenceTests(unittest.TestCase):
self.assertFalse((self.screenshots / image.name).exists())
self.assertEqual(unrelated.read_text(), "preserve")
def gallery(self):
parent = self.fixture().parent
for name in HARNESS.SCREENSHOTS:
(parent / name).write_bytes(PNG)
(parent / "viewport.json").write_text(json.dumps({"widthPx": 720, "heightPx": 1184, "density": 2}))
return parent
def test_collects_complete_allowlisted_gallery(self):
parent = self.gallery()
(parent / "private.txt").write_text("not exported")
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
self.assertEqual({p.name for p in self.screenshots.iterdir()}, set(HARNESS.VISUAL_OUTPUTS))
def test_partial_gallery_is_not_exported(self):
parent = self.gallery()
(parent / "notes-dark.png").unlink()
with self.assertRaisesRegex(AssertionError, "Missing or ambiguous"):
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
self.assertFalse(self.screenshots.exists())
def test_clears_every_known_gallery_file_but_preserves_unrelated(self):
parent = self.gallery()
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
(parent / "other.png").write_bytes(PNG)
HARNESS.prepare_android_screenshots(self.output)
self.assertEqual([p.name for p in parent.iterdir()], ["other.png"])
self.assertEqual(list(self.screenshots.iterdir()), [])
def test_rejects_gallery_split_across_devices(self):
parent = self.gallery()
other = parent.with_name("second")
other.mkdir()
(parent / "notes-dark.png").rename(other / "notes-dark.png")
with self.assertRaisesRegex(AssertionError, "Mixed-device"):
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
if __name__ == "__main__":
unittest.main()
+24 -8
View File
@@ -20,25 +20,41 @@ import urllib.request
ROOT = Path(__file__).resolve().parents[1]
REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d"
SCREENSHOTS = ("native-dashboard.png",) + tuple(
f"{page}-{theme}.png" for page in ("login", "dashboard", "tasks", "notes", "shopping") for theme in ("light", "dark"))
VISUAL_OUTPUTS = SCREENSHOTS + ("viewport.json",)
def prepare_android_screenshots(app_output):
additional = app_output / "connected_android_test_additional_output/integrationAndroidTest"
screenshots = app_output / "native-screenshots"
# Never accept a previous run's screenshot as current evidence.
for stale in additional.rglob("native-dashboard.png"):
for name in VISUAL_OUTPUTS:
for stale in additional.rglob(name):
stale.unlink()
(screenshots / "native-dashboard.png").unlink(missing_ok=True)
(screenshots / name).unlink(missing_ok=True)
return additional, screenshots
def collect_native_screenshot(additional, screenshots):
captures = list(additional.rglob("native-dashboard.png"))
assert len(captures) == 1, "Missing or ambiguous native fixture screenshot"
def collect_native_screenshot(additional, screenshots, gallery=False):
names = VISUAL_OUTPUTS if gallery else ("native-dashboard.png",)
validated = {}
parents = set()
for name in names:
captures = list(additional.rglob(name))
assert len(captures) == 1, f"Missing or ambiguous native fixture screenshot: {name}"
capture = captures[0].read_bytes()
if name.endswith(".png"):
assert capture.startswith(b"\x89PNG\r\n\x1a\n"), "Invalid native fixture screenshot"
else:
viewport = json.loads(capture)
assert viewport["widthPx"] > 0 and viewport["heightPx"] > 0 and viewport["density"] > 0
parents.add(captures[0].parent)
validated[name] = capture
assert len(parents) == 1, "Mixed-device native gallery evidence"
screenshots.mkdir(parents=True, exist_ok=True)
(screenshots / "native-dashboard.png").write_bytes(capture)
for name, capture in validated.items():
(screenshots / name).write_bytes(capture)
def run(upstream, android, cookie_without_secure=False):
@@ -152,8 +168,8 @@ def run(upstream, android, cookie_without_secure=False):
raise RuntimeError("Android integration tests failed. Inspect the Gradle test report.")
reports = app_output / "androidTest-results/connected/integration"
subprocess.run(["python3", str(ROOT / "scripts/verify-test-results.py"),
str(reports), "--minimum", "7"], check=True)
collect_native_screenshot(additional, screenshots)
str(reports), "--minimum", "10"], check=True)
collect_native_screenshot(additional, screenshots, gallery=True)
finally:
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{port}"], check=False)
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{ready['idpPort']}"], check=False)