SHA256
feat(android): align native preview with measured PWA design
This commit is contained in:
1 parent
bd0e4cc4fb
commit
7ac9882307
30 files changed
+2764
-721
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+111
-14
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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)
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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.`);
|
||||
@@ -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);
|
||||
process.env.OIDC_ISSUER = idp.issuer;
|
||||
process.env.OIDC_CLIENT_ID = idp.clientId;
|
||||
process.env.OIDC_CLIENT_SECRET = idp.secret;
|
||||
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');
|
||||
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); });
|
||||
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 ?? null }));
|
||||
process.on('SIGTERM', () => { idp?.close(); proxy.close(); server.close(); process.exit(0); });
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user