SHA256
feat(android): align native preview with measured PWA design
This commit is contained in:
1 parent
bd0e4cc4fb
commit
7ac9882307
30 files changed
+2759
-716
No files matched your search
@@ -98,7 +98,7 @@ jobs:
|
|||||||
disable-animations: true
|
disable-animations: true
|
||||||
script: |
|
script: |
|
||||||
bash android/gradlew -p android connectedDebugAndroidTest
|
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
|
||||||
python3 scripts/test-integration.py "$YUVOMI_UPSTREAM" --android --cookie-without-secure
|
python3 scripts/test-integration.py "$YUVOMI_UPSTREAM" --android --cookie-without-secure
|
||||||
- uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de
|
- uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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)
|
### Android 0.2.1 native preview (code 3)
|
||||||
|
|
||||||
- Fix login against upstream-default session cookies without `Secure`: Android
|
- 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)
|
- [Architecture](docs/architecture.md)
|
||||||
- [Native preview delivery report](docs/native-delivery-report.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)
|
- [Android build](docs/build-android.md) / [iOS build](docs/build-ios.md)
|
||||||
- [Compatibility and authentication](docs/server-compatibility.md)
|
- [Compatibility and authentication](docs/server-compatibility.md)
|
||||||
- [Release process](docs/release-process.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
|
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
|
colors (`public/utils/brand-mark.js` and `docs/logo.svg`); its MIT attribution is
|
||||||
preserved below. No affiliation or trademark ownership is implied.
|
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
|
Upstream Yuvomi: https://github.com/ulsklyc/yuvomi
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ android {
|
|||||||
applicationId = "community.yuvomi.mobile"
|
applicationId = "community.yuvomi.mobile"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 3
|
versionCode = 4
|
||||||
versionName = "0.2.1-native-preview"
|
versionName = "0.2.2-native-preview"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
buildTypes {
|
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
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
|
import android.app.UiModeManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.SystemClock
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
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.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.onRoot
|
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performScrollTo
|
import androidx.compose.ui.test.performScrollTo
|
||||||
import androidx.compose.ui.test.performTextInput
|
import androidx.compose.ui.test.performTextInput
|
||||||
@@ -33,6 +35,9 @@ class NativeUpstreamTest {
|
|||||||
val reset = object : ExternalResource() {
|
val reset = object : ExternalResource() {
|
||||||
override fun before() {
|
override fun before() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
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-server", Context.MODE_PRIVATE).edit().clear().commit()
|
||||||
context.getSharedPreferences("native-session", Context.MODE_PRIVATE).edit().clear().commit()
|
context.getSharedPreferences("native-session", Context.MODE_PRIVATE).edit().clear().commit()
|
||||||
if (android.os.Build.VERSION.SDK_INT >= 37) {
|
if (android.os.Build.VERSION.SDK_INT >= 37) {
|
||||||
@@ -72,10 +77,31 @@ class NativeUpstreamTest {
|
|||||||
org.json.JSONObject().put("name", "Wochenmarkt"),
|
org.json.JSONObject().put("name", "Wochenmarkt"),
|
||||||
).getJSONObject("data").getLong("id")
|
).getJSONObject("data").getLong("id")
|
||||||
seed.request("/shopping/$seedList/items", "POST", org.json.JSONObject().put("name", "Äpfel").put("quantity", "4"))
|
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")
|
seed.request("/auth/logout", "POST")
|
||||||
compose.onNodeWithTag("server-url").performTextInput(origin)
|
compose.onNodeWithTag("server-url").performTextInput(origin)
|
||||||
compose.onNodeWithTag("connect").performClick()
|
compose.onNodeWithTag("connect").performClick()
|
||||||
awaitTag("username")
|
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("username").performTextInput("mobiletest")
|
||||||
compose.onNodeWithTag("password").performTextInput(password + "-invalid")
|
compose.onNodeWithTag("password").performTextInput(password + "-invalid")
|
||||||
compose.onNodeWithTag("login").performScrollTo().performClick()
|
compose.onNodeWithTag("login").performScrollTo().performClick()
|
||||||
@@ -87,15 +113,23 @@ class NativeUpstreamTest {
|
|||||||
compose.activityRule.scenario.onActivity {
|
compose.activityRule.scenario.onActivity {
|
||||||
assertEquals(null, ViewModelProvider(it)[NativeController::class.java].state.error)
|
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.CREATED)
|
||||||
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||||
compose.activityRule.scenario.recreate()
|
compose.activityRule.scenario.recreate()
|
||||||
awaitTag("native-dashboard")
|
awaitTag("native-dashboard")
|
||||||
|
|
||||||
compose.onNodeWithTag("nav-TASKS").performClick()
|
compose.onNodeWithTag("nav-TASKS").performClick()
|
||||||
awaitText("Hinzufügen")
|
awaitTag("add-entry")
|
||||||
compose.onNodeWithText("Hinzufügen").performClick()
|
compose.onNodeWithTag("add-entry").performClick()
|
||||||
compose.onNodeWithTag("entry-title").performTextInput("Native task regression")
|
compose.onNodeWithTag("entry-title").performTextInput("Native task regression")
|
||||||
compose.onNodeWithTag("entry-detail").performTextInput("Created with native controls")
|
compose.onNodeWithTag("entry-detail").performTextInput("Created with native controls")
|
||||||
compose.onNodeWithTag("entry-save").performClick()
|
compose.onNodeWithTag("entry-save").performClick()
|
||||||
@@ -103,8 +137,8 @@ class NativeUpstreamTest {
|
|||||||
assertTrue(readApi(origin).request("/tasks").rows().any { it.text("title") == "Native task regression" })
|
assertTrue(readApi(origin).request("/tasks").rows().any { it.text("title") == "Native task regression" })
|
||||||
|
|
||||||
compose.onNodeWithTag("nav-NOTES").performClick()
|
compose.onNodeWithTag("nav-NOTES").performClick()
|
||||||
awaitText("Hinzufügen")
|
awaitTag("add-entry")
|
||||||
compose.onNodeWithText("Hinzufügen").performClick()
|
compose.onNodeWithTag("add-entry").performClick()
|
||||||
compose.onNodeWithTag("entry-title").performTextInput("Native note regression")
|
compose.onNodeWithTag("entry-title").performTextInput("Native note regression")
|
||||||
compose.onNodeWithTag("entry-detail").performTextInput("Not an embedded web page")
|
compose.onNodeWithTag("entry-detail").performTextInput("Not an embedded web page")
|
||||||
compose.onNodeWithTag("entry-save").performClick()
|
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" })
|
assertTrue(readApi(origin).request("/notes").rows().any { it.text("content") == "Not an embedded web page" })
|
||||||
|
|
||||||
compose.onNodeWithTag("nav-SHOPPING").performClick()
|
compose.onNodeWithTag("nav-SHOPPING").performClick()
|
||||||
awaitText("Neue Liste")
|
awaitTag("add-list")
|
||||||
compose.onNodeWithText("Neue Liste").performClick()
|
compose.onNodeWithTag("add-list").performClick()
|
||||||
compose.onNodeWithTag("entry-title").performTextInput("Native shopping regression")
|
compose.onNodeWithTag("entry-title").performTextInput("Native shopping regression")
|
||||||
compose.onNodeWithTag("entry-save").performClick()
|
compose.onNodeWithTag("entry-save").performClick()
|
||||||
awaitText("Native shopping regression")
|
awaitText("Native shopping regression")
|
||||||
compose.onNodeWithText("Native shopping regression").performClick()
|
compose.onNodeWithText("Native shopping regression").performClick()
|
||||||
awaitText("Hinzufügen")
|
awaitTag("add-entry")
|
||||||
compose.onNodeWithText("Hinzufügen").performClick()
|
compose.onNodeWithTag("add-entry").performClick()
|
||||||
compose.onNodeWithTag("entry-title").performTextInput("Native apples")
|
compose.onNodeWithTag("entry-title").performTextInput("Native apples")
|
||||||
compose.onNodeWithTag("entry-detail").performTextInput("3")
|
compose.onNodeWithTag("entry-detail").performTextInput("3")
|
||||||
compose.onNodeWithTag("entry-save").performClick()
|
compose.onNodeWithTag("entry-save").performClick()
|
||||||
@@ -157,15 +191,67 @@ class NativeUpstreamTest {
|
|||||||
return api
|
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.
|
// Only this temporary-fixture test clears the screenshot restriction.
|
||||||
// Ordinary builds always retain FLAG_SECURE, including native login.
|
// 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 {
|
try {
|
||||||
compose.waitForIdle()
|
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
|
// Capture the native window through PixelCopy, not a potentially stale
|
||||||
// secure compositor frame returned by UiAutomation immediately after clearing.
|
// 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 {
|
try {
|
||||||
val first = image.getPixel(0, 0)
|
val first = image.getPixel(0, 0)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
@@ -175,9 +261,20 @@ class NativeUpstreamTest {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
// AGP collects this test output before uninstalling the fixture APK.
|
// 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))
|
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 {
|
} finally {
|
||||||
image.recycle()
|
image.recycle()
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -31,6 +31,8 @@ data class NativeState(
|
|||||||
val resetAllowed: Boolean = false,
|
val resetAllowed: Boolean = false,
|
||||||
val page: NativePage = NativePage.HOME,
|
val page: NativePage = NativePage.HOME,
|
||||||
val dashboard: JSONObject = JSONObject(),
|
val dashboard: JSONObject = JSONObject(),
|
||||||
|
val preferences: JSONObject = JSONObject(),
|
||||||
|
val shoppingLists: List<JSONObject> = emptyList(),
|
||||||
val rows: List<JSONObject> = emptyList(),
|
val rows: List<JSONObject> = emptyList(),
|
||||||
val listId: Long? = null,
|
val listId: Long? = null,
|
||||||
val listName: String = "",
|
val listName: String = "",
|
||||||
@@ -77,6 +79,8 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
|||||||
user = null,
|
user = null,
|
||||||
permissions = JSONObject(),
|
permissions = JSONObject(),
|
||||||
dashboard = JSONObject(),
|
dashboard = JSONObject(),
|
||||||
|
preferences = JSONObject(),
|
||||||
|
shoppingLists = emptyList(),
|
||||||
rows = emptyList(),
|
rows = emptyList(),
|
||||||
page = NativePage.HOME,
|
page = NativePage.HOME,
|
||||||
listId = null,
|
listId = null,
|
||||||
@@ -168,6 +172,12 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
|||||||
requireNotNull(client).requireSessionCookie()
|
requireNotNull(client).requireSessionCookie()
|
||||||
val user = response.getJSONObject("user")
|
val user = response.getJSONObject("user")
|
||||||
val permissions = response.getJSONObject("permissions")
|
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(
|
val signedIn = previous.copy(
|
||||||
user = user,
|
user = user,
|
||||||
permissions = permissions,
|
permissions = permissions,
|
||||||
@@ -175,6 +185,8 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
|||||||
twoFactor = false,
|
twoFactor = false,
|
||||||
page = NativePage.HOME,
|
page = NativePage.HOME,
|
||||||
dashboard = JSONObject(),
|
dashboard = JSONObject(),
|
||||||
|
preferences = preferences,
|
||||||
|
shoppingLists = emptyList(),
|
||||||
rows = emptyList(),
|
rows = emptyList(),
|
||||||
)
|
)
|
||||||
return try {
|
return try {
|
||||||
@@ -196,7 +208,16 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
|||||||
NativePage.HOME -> previous.copy(dashboard = api.request("/dashboard"))
|
NativePage.HOME -> previous.copy(dashboard = api.request("/dashboard"))
|
||||||
NativePage.TASKS -> previous.copy(rows = api.request("/tasks").rows())
|
NativePage.TASKS -> previous.copy(rows = api.request("/tasks").rows())
|
||||||
NativePage.NOTES -> previous.copy(rows = api.request("/notes").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
|
NativePage.MORE -> previous
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,6 +291,21 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
|||||||
load(previous)
|
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 ->
|
fun checkItem(item: JSONObject) = perform { previous ->
|
||||||
requireNotNull(client).request(
|
requireNotNull(client).request(
|
||||||
"/shopping/items/${item.getLong("id")}",
|
"/shopping/items/${item.getLong("id")}",
|
||||||
@@ -309,6 +345,8 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
|||||||
user = null,
|
user = null,
|
||||||
permissions = JSONObject(),
|
permissions = JSONObject(),
|
||||||
dashboard = JSONObject(),
|
dashboard = JSONObject(),
|
||||||
|
preferences = JSONObject(),
|
||||||
|
shoppingLists = emptyList(),
|
||||||
rows = emptyList(),
|
rows = emptyList(),
|
||||||
listId = null,
|
listId = null,
|
||||||
page = NativePage.HOME,
|
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**.
|
shopping slices exist, but **no module has accepted full visual/functional parity**.
|
||||||
The remaining pages/settings/auth/file/offline/notification work is explicit.
|
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
|
### Android login follow-up: 0.2.1 native preview / code 3
|
||||||
|
|
||||||
A user reported immediate HTTP-401 login failure although the same direct password
|
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.
|
- **Parity accepted**: requires functional and visual evidence on both platforms.
|
||||||
**No module currently has this status.**
|
**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
|
## Page inventory
|
||||||
|
|
||||||
| Upstream page | Native status | Work still required before parity |
|
| 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
|
tests, and Swift production contracts/transport tests. Archived WebView tests are
|
||||||
not included in the native suites or counted toward native completion.
|
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
|
## Android 0.2.1 / code 3: reproduced login regression
|
||||||
|
|
||||||
The user reported immediate login failure despite a working direct browser login.
|
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.NODE_ENV = 'test';
|
||||||
process.env.LOG_LEVEL = 'warn';
|
process.env.LOG_LEVEL = 'warn';
|
||||||
const tls = { key: fs.readFileSync(path.join(directory, 'key.pem')), cert: fs.readFileSync(path.join(directory, 'cert.pem')) };
|
const tls = { key: fs.readFileSync(path.join(directory, 'key.pem')), cert: fs.readFileSync(path.join(directory, 'cert.pem')) };
|
||||||
const idp = await startIdentityProvider(tls);
|
const idp = process.env.YUVOMI_TEST_DISABLE_OIDC === 'true' ? null : await startIdentityProvider(tls);
|
||||||
|
if (idp) {
|
||||||
process.env.OIDC_ISSUER = idp.issuer;
|
process.env.OIDC_ISSUER = idp.issuer;
|
||||||
process.env.OIDC_CLIENT_ID = idp.clientId;
|
process.env.OIDC_CLIENT_ID = idp.clientId;
|
||||||
process.env.OIDC_CLIENT_SECRET = idp.secret;
|
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')));
|
const { server } = await import(pathToFileURL(path.join(upstream, 'server/index.js')));
|
||||||
if (!server.listening) await once(server, 'listening');
|
if (!server.listening) await once(server, 'listening');
|
||||||
let networkAvailable = true;
|
let networkAvailable = true;
|
||||||
@@ -50,7 +56,9 @@ const proxy = https.createServer(tls, (request, response) => {
|
|||||||
});
|
});
|
||||||
proxy.listen(0, '127.0.0.1');
|
proxy.listen(0, '127.0.0.1');
|
||||||
await once(proxy, 'listening');
|
await once(proxy, 'listening');
|
||||||
|
if (idp) {
|
||||||
process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`;
|
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);
|
idp.setRedirect(process.env.OIDC_REDIRECT_URI);
|
||||||
fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp.port }));
|
}
|
||||||
process.on('SIGTERM', () => { idp.close(); proxy.close(); server.close(); process.exit(0); });
|
fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp?.port ?? null }));
|
||||||
|
process.on('SIGTERM', () => { idp?.close(); proxy.close(); server.close(); process.exit(0); });
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"""Regressions for AGP screenshot output collection, not visual parity tests."""
|
"""Regressions for AGP screenshot output collection, not visual parity tests."""
|
||||||
import base64
|
import base64
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -57,6 +58,42 @@ class ScreenshotEvidenceTests(unittest.TestCase):
|
|||||||
self.assertFalse((self.screenshots / image.name).exists())
|
self.assertFalse((self.screenshots / image.name).exists())
|
||||||
self.assertEqual(unrelated.read_text(), "preserve")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -20,25 +20,41 @@ import urllib.request
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d"
|
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):
|
def prepare_android_screenshots(app_output):
|
||||||
additional = app_output / "connected_android_test_additional_output/integrationAndroidTest"
|
additional = app_output / "connected_android_test_additional_output/integrationAndroidTest"
|
||||||
screenshots = app_output / "native-screenshots"
|
screenshots = app_output / "native-screenshots"
|
||||||
# Never accept a previous run's screenshot as current evidence.
|
# 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()
|
stale.unlink()
|
||||||
(screenshots / "native-dashboard.png").unlink(missing_ok=True)
|
(screenshots / name).unlink(missing_ok=True)
|
||||||
return additional, screenshots
|
return additional, screenshots
|
||||||
|
|
||||||
|
|
||||||
def collect_native_screenshot(additional, screenshots):
|
def collect_native_screenshot(additional, screenshots, gallery=False):
|
||||||
captures = list(additional.rglob("native-dashboard.png"))
|
names = VISUAL_OUTPUTS if gallery else ("native-dashboard.png",)
|
||||||
assert len(captures) == 1, "Missing or ambiguous native fixture screenshot"
|
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()
|
capture = captures[0].read_bytes()
|
||||||
|
if name.endswith(".png"):
|
||||||
assert capture.startswith(b"\x89PNG\r\n\x1a\n"), "Invalid native fixture screenshot"
|
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.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):
|
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.")
|
raise RuntimeError("Android integration tests failed. Inspect the Gradle test report.")
|
||||||
reports = app_output / "androidTest-results/connected/integration"
|
reports = app_output / "androidTest-results/connected/integration"
|
||||||
subprocess.run(["python3", str(ROOT / "scripts/verify-test-results.py"),
|
subprocess.run(["python3", str(ROOT / "scripts/verify-test-results.py"),
|
||||||
str(reports), "--minimum", "7"], check=True)
|
str(reports), "--minimum", "10"], check=True)
|
||||||
collect_native_screenshot(additional, screenshots)
|
collect_native_screenshot(additional, screenshots, gallery=True)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{port}"], check=False)
|
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{port}"], check=False)
|
||||||
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{ready['idpPort']}"], check=False)
|
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{ready['idpPort']}"], check=False)
|
||||||
|
|||||||
Reference in new issue
Block a user