diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index 78d98bc..feed9b2 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -98,7 +98,7 @@ jobs: disable-animations: true script: | bash android/gradlew -p android connectedDebugAndroidTest - python3 scripts/verify-test-results.py android/app/build/outputs/androidTest-results/connected/debug --minimum 6 + python3 scripts/verify-test-results.py android/app/build/outputs/androidTest-results/connected/debug --minimum 9 python3 scripts/test-integration.py "$YUVOMI_UPSTREAM" --android python3 scripts/test-integration.py "$YUVOMI_UPSTREAM" --android --cookie-without-secure - uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de diff --git a/CHANGELOG.md b/CHANGELOG.md index c59de5c..8cb37a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +### Android 0.2.2 native preview (code 4) + +- Replace the initial Material-style slice with native PWA-derived typography, + light/dark colors, module icons/seals, compact cards, list rows, capsule + navigation and bottom-sheet editors. Remove the duplicate overview heading. +- Read personal dashboard/favorite preferences, select the first shopping list, + and save supported widget visibility without deleting unsupported config. +- Add real-PWA reference capture and a native ten-view light/dark screenshot + gallery with measured content viewport; browser automation is tooling only. +- Preserve the confirmed HTTPS login/session fix. This is an Android design pass, + **not accepted 1:1 parity**; iOS and remaining modules are unchanged/incomplete. + ### Android 0.2.1 native preview (code 3) - Fix login against upstream-default session cookies without `Secure`: Android diff --git a/README.md b/README.md index 78a6454..cc69e8f 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ import a browser/WebView session; sign in again after upgrading the prototype. - [Architecture](docs/architecture.md) - [Native preview delivery report](docs/native-delivery-report.md) +- [PWA design comparison and remaining differences](docs/native-design.md) - [Android build](docs/build-android.md) / [iOS build](docs/build-ios.md) - [Compatibility and authentication](docs/server-compatibility.md) - [Release process](docs/release-process.md) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5f10d4e..7f8147a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -6,6 +6,10 @@ Native design-token mappings follow upstream DESIGN.md/styles. The native three-circle brand mark is redrawn from upstream's published brand geometry and colors (`public/utils/brand-mark.js` and `docs/logo.svg`); its MIT attribution is preserved below. No affiliation or trademark ownership is implied. +Android's `PwaIcons.kt` also adapts the module paths from upstream +`public/nav-icons.js`; the native palette, spacing and typography follow its +`public/styles/{tokens,typography,layout,dashboard,auth,tasks,notes,shopping}.css`. +The reference browser tooling is development-only and is not shipped in the apps. Upstream Yuvomi: https://github.com/ulsklyc/yuvomi diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ce2d792..0fbca3f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -10,8 +10,8 @@ android { applicationId = "community.yuvomi.mobile" minSdk = 26 targetSdk = 37 - versionCode = 3 - versionName = "0.2.1-native-preview" + versionCode = 4 + versionName = "0.2.2-native-preview" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { diff --git a/android/app/src/androidTestNative/java/community/yuvomi/mobile/PwaComponentsTest.kt b/android/app/src/androidTestNative/java/community/yuvomi/mobile/PwaComponentsTest.kt new file mode 100644 index 0000000..2a0ac7e --- /dev/null +++ b/android/app/src/androidTestNative/java/community/yuvomi/mobile/PwaComponentsTest.kt @@ -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() + + 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() + } +} diff --git a/android/app/src/androidTestNativeIntegration/java/community/yuvomi/mobile/NativeUpstreamTest.kt b/android/app/src/androidTestNativeIntegration/java/community/yuvomi/mobile/NativeUpstreamTest.kt index 6a0e4d1..f5aa2f8 100644 --- a/android/app/src/androidTestNativeIntegration/java/community/yuvomi/mobile/NativeUpstreamTest.kt +++ b/android/app/src/androidTestNativeIntegration/java/community/yuvomi/mobile/NativeUpstreamTest.kt @@ -1,7 +1,10 @@ package community.yuvomi.mobile +import android.app.UiModeManager import android.content.Context import android.graphics.Bitmap +import android.os.Build +import android.os.SystemClock import android.view.WindowManager import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.assertIsDisplayed @@ -11,7 +14,6 @@ import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput @@ -33,6 +35,9 @@ class NativeUpstreamTest { val reset = object : ExternalResource() { override fun before() { val context = ApplicationProvider.getApplicationContext() + if (Build.VERSION.SDK_INT >= 31) { + context.getSystemService(UiModeManager::class.java).setApplicationNightMode(UiModeManager.MODE_NIGHT_NO) + } context.getSharedPreferences("native-server", Context.MODE_PRIVATE).edit().clear().commit() context.getSharedPreferences("native-session", Context.MODE_PRIVATE).edit().clear().commit() if (android.os.Build.VERSION.SDK_INT >= 37) { @@ -72,10 +77,31 @@ class NativeUpstreamTest { org.json.JSONObject().put("name", "Wochenmarkt"), ).getJSONObject("data").getLong("id") seed.request("/shopping/$seedList/items", "POST", org.json.JSONObject().put("name", "Äpfel").put("quantity", "4")) + seed.request( + "/preferences", + "PUT", + org.json.JSONObject() + .put( + "dashboard_widgets", + org.json.JSONArray( + listOf("tasks", "calendar", "shopping", "notes").mapIndexed { order, id -> + org.json.JSONObject().put("id", id).put("visible", true).put("order", order).put("size", "1x1") + }, + ), + ) + .put("mobile_nav_order", org.json.JSONArray(listOf("tasks", "kitchen", "notes"))) + .put("timezone", "Europe/Berlin"), + ) seed.request("/auth/logout", "POST") compose.onNodeWithTag("server-url").performTextInput(origin) compose.onNodeWithTag("connect").performClick() awaitTag("username") + captureFixtureScreen("login-light.png") + nightMode(true) + awaitTag("username") + captureFixtureScreen("login-dark.png") + nightMode(false) + awaitTag("username") compose.onNodeWithTag("username").performTextInput("mobiletest") compose.onNodeWithTag("password").performTextInput(password + "-invalid") compose.onNodeWithTag("login").performScrollTo().performClick() @@ -87,15 +113,23 @@ class NativeUpstreamTest { compose.activityRule.scenario.onActivity { assertEquals(null, ViewModelProvider(it)[NativeController::class.java].state.error) } - captureFixtureDashboard() + captureFixtureScreen("native-dashboard.png") + captureFixtureScreen("dashboard-light.png") + captureModuleGallery("light") + nightMode(true) + awaitTag("native-dashboard") + captureFixtureScreen("dashboard-dark.png") + captureModuleGallery("dark") + nightMode(false) + awaitTag("native-dashboard") compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED) compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) compose.activityRule.scenario.recreate() awaitTag("native-dashboard") compose.onNodeWithTag("nav-TASKS").performClick() - awaitText("Hinzufügen") - compose.onNodeWithText("Hinzufügen").performClick() + awaitTag("add-entry") + compose.onNodeWithTag("add-entry").performClick() compose.onNodeWithTag("entry-title").performTextInput("Native task regression") compose.onNodeWithTag("entry-detail").performTextInput("Created with native controls") compose.onNodeWithTag("entry-save").performClick() @@ -103,8 +137,8 @@ class NativeUpstreamTest { assertTrue(readApi(origin).request("/tasks").rows().any { it.text("title") == "Native task regression" }) compose.onNodeWithTag("nav-NOTES").performClick() - awaitText("Hinzufügen") - compose.onNodeWithText("Hinzufügen").performClick() + awaitTag("add-entry") + compose.onNodeWithTag("add-entry").performClick() compose.onNodeWithTag("entry-title").performTextInput("Native note regression") compose.onNodeWithTag("entry-detail").performTextInput("Not an embedded web page") compose.onNodeWithTag("entry-save").performClick() @@ -112,14 +146,14 @@ class NativeUpstreamTest { assertTrue(readApi(origin).request("/notes").rows().any { it.text("content") == "Not an embedded web page" }) compose.onNodeWithTag("nav-SHOPPING").performClick() - awaitText("Neue Liste") - compose.onNodeWithText("Neue Liste").performClick() + awaitTag("add-list") + compose.onNodeWithTag("add-list").performClick() compose.onNodeWithTag("entry-title").performTextInput("Native shopping regression") compose.onNodeWithTag("entry-save").performClick() awaitText("Native shopping regression") compose.onNodeWithText("Native shopping regression").performClick() - awaitText("Hinzufügen") - compose.onNodeWithText("Hinzufügen").performClick() + awaitTag("add-entry") + compose.onNodeWithTag("add-entry").performClick() compose.onNodeWithTag("entry-title").performTextInput("Native apples") compose.onNodeWithTag("entry-detail").performTextInput("3") compose.onNodeWithTag("entry-save").performClick() @@ -157,15 +191,67 @@ class NativeUpstreamTest { return api } - private fun captureFixtureDashboard() { + private fun nightMode(dark: Boolean) { + check(Build.VERSION.SDK_INT >= 31) { "The reference gallery requires per-app night mode (API 31+)" } + compose.activityRule.scenario.onActivity { + it.getSystemService(UiModeManager::class.java).setApplicationNightMode( + if (dark) UiModeManager.MODE_NIGHT_YES else UiModeManager.MODE_NIGHT_NO, + ) + } + compose.waitUntil(10_000) { + var matches = false + compose.activityRule.scenario.onActivity { + matches = ( + it.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK == + android.content.res.Configuration.UI_MODE_NIGHT_YES + ) == dark + } + matches + } + compose.waitForIdle() + } + + private fun captureModuleGallery(theme: String) { + listOf(NativePage.TASKS, NativePage.NOTES, NativePage.SHOPPING).forEach { page -> + compose.onNodeWithTag("nav-${page.name}").performClick() + compose.waitUntil(30_000) { + var ready = false + compose.activityRule.scenario.onActivity { + val state = ViewModelProvider(it)[NativeController::class.java].state + ready = !state.busy && state.page == page && state.rows.isNotEmpty() + } + ready + } + captureFixtureScreen("${page.name.lowercase()}-$theme.png") + } + compose.onNodeWithTag("nav-HOME").performClick() + awaitTag("native-dashboard") + } + + private fun captureFixtureScreen(name: String) { // Only this temporary-fixture test clears the screenshot restriction. // Ordinary builds always retain FLAG_SECURE, including native login. - compose.activityRule.scenario.onActivity { it.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } + compose.activityRule.scenario.onActivity { + it.currentFocus?.clearFocus() + it.window.insetsController?.hide(android.view.WindowInsets.Type.ime()) + it.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } try { compose.waitForIdle() + compose.waitUntil(10_000) { + var hidden = false + compose.activityRule.scenario.onActivity { + hidden = it.window.decorView.rootWindowInsets?.isVisible(android.view.WindowInsets.Type.ime()) != true + } + hidden + } + // PixelCopy must run after the real window/inset animation, not only Compose idleness. + SystemClock.sleep(600) // Capture the native window through PixelCopy, not a potentially stale // secure compositor frame returned by UiAutomation immediately after clearing. - val image = compose.onRoot().captureToImage().asAndroidBitmap() + val node = compose.onNodeWithTag("native-screen") + val bounds = node.fetchSemanticsNode().boundsInRoot + val image = node.captureToImage().asAndroidBitmap() try { val first = image.getPixel(0, 0) assertTrue( @@ -175,9 +261,20 @@ class NativeUpstreamTest { }, ) // AGP collects this test output before uninstalling the fixture APK. - PlatformTestStorageRegistry.getInstance().openOutputFile("native-dashboard.png").use { + PlatformTestStorageRegistry.getInstance().openOutputFile(name).use { check(image.compress(Bitmap.CompressFormat.PNG, 100, it)) } + if (name == "native-dashboard.png") { + val resources = compose.activity.resources + val metadata = org.json.JSONObject() + .put("widthPx", image.width).put("heightPx", image.height) + .put("density", resources.displayMetrics.density).put("fontScale", resources.configuration.fontScale) + .put("sdk", Build.VERSION.SDK_INT) + .put("boundsInRoot", org.json.JSONArray(listOf(bounds.left, bounds.top, bounds.right, bounds.bottom))) + PlatformTestStorageRegistry.getInstance().openOutputFile("viewport.json").use { + it.write(metadata.toString(2).toByteArray()) + } + } } finally { image.recycle() } diff --git a/android/app/src/main/java/community/yuvomi/mobile/NativeApp.kt b/android/app/src/main/java/community/yuvomi/mobile/NativeApp.kt index 97825d6..d1aac46 100644 --- a/android/app/src/main/java/community/yuvomi/mobile/NativeApp.kt +++ b/android/app/src/main/java/community/yuvomi/mobile/NativeApp.kt @@ -1,48 +1,34 @@ package community.yuvomi.mobile import androidx.activity.compose.BackHandler -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.border 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.ColumnScope -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing 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.items +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilterChip -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -51,264 +37,251 @@ 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.geometry.CornerRadius +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -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.TextDecoration +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.util.Locale import org.json.JSONObject -private val Violet = Color(0xFF6C3AED) -private val PageShape = RoundedCornerShape(16.dp) - +/** Native controls only. The PWA is a measured design reference, not the renderer. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun NativeApp(controller: NativeController, needsLocalNetwork: Boolean = false, requestLocalNetwork: () -> Unit = {}) { - val dark = isSystemInDarkTheme() - val palette = if (dark) { - darkColorScheme( - primary = Color(0xFFA78BFA), - background = Color(0xFF191816), - surface = Color(0xFF2B2825), - onSurface = Color(0xFFF5F3ED), - onSurfaceVariant = Color(0xFFB4AEA5), - outline = Color(0xFF948E85), - onBackground = Color(0xFFF5F3ED), - ) - } else { - lightColorScheme( - primary = Violet, - background = Color(0xFFF5F3ED), - surface = Color.White, - onSurface = Color(0xFF1D1B17), - onSurfaceVariant = Color(0xFF63615B), - outline = Color(0xFF8C8880), - onBackground = Color(0xFF1D1B17), - ) - } - MaterialTheme(colorScheme = palette) { + PwaTheme { val state = controller.state - var confirmForget by remember { mutableStateOf(false) } - var editor by remember { mutableStateOf(null) } + val colors = LocalPwaPalette.current var editing by remember { mutableStateOf(false) } + var editor by remember { mutableStateOf(null) } + var creatingList by remember { mutableStateOf(false) } var submitted by remember { mutableStateOf(false) } + var pendingCreate by remember { mutableStateOf(null) } + var more by remember { mutableStateOf(false) } + var createMenu by remember { mutableStateOf(false) } + var forget by remember { mutableStateOf(false) } var remove by remember { mutableStateOf(null) } var info by remember { mutableStateOf(null) } + var customize by remember { mutableStateOf(false) } + val layer = rememberGraphicsLayer() + var contentOrigin by remember { mutableStateOf(Offset.Zero) } + + fun create(page: NativePage, list: Boolean = false) { + editor = null + creatingList = list + createMenu = false + more = false + if (state.page == page) { + editing = true + } else { + pendingCreate = page + controller.navigate(page) + } + } + LaunchedEffect(state.user?.text("id"), state.server) { editing = false editor = null submitted = false + more = false + createMenu = false + pendingCreate = null } LaunchedEffect(state.busy, state.error) { if (submitted && !state.busy) { submitted = false if (state.error == null) editing = false } + if (pendingCreate != null && !state.busy) { + editing = state.page == pendingCreate && state.error == null + pendingCreate = null + } } - BackHandler(state.user != null && (state.page != NativePage.HOME || state.listId != null)) { - if (!state.busy) controller.navigate(if (state.listId != null) NativePage.SHOPPING else NativePage.HOME) + BackHandler(state.user != null && state.page != NativePage.HOME && !more && !editing) { + if (!state.busy) controller.navigate(NativePage.HOME) } - Surface(Modifier.fillMaxSize(), color = palette.background) { - if (state.server.isEmpty() || state.user == null) { - AuthenticationScreen( - state, - needsLocalNetwork, - requestLocalNetwork, - controller::connect, - controller::login, - controller::verify, - controller::cancelTwoFactor, - { confirmForget = true }, { - info = - "SSO wird für den nativen API-Client noch nicht unterstützt. " + - "Es wird keine Browser-Sitzung als native Anmeldung vorgetäuscht." - }, - ) - } else { - Scaffold( - containerColor = palette.background, - bottomBar = { - Surface( - Modifier.padding(horizontal = 12.dp, vertical = 8.dp).fillMaxWidth(), - shape = RoundedCornerShape(32.dp), - shadowElevation = 8.dp, - ) { - Row(Modifier.padding(6.dp), horizontalArrangement = Arrangement.SpaceEvenly) { - NativePage.entries.filter { - it == NativePage.HOME || it == NativePage.MORE || - state.access(it.name.lowercase()) != "none" - }.forEach { page -> - Column( - Modifier.weight(1f).clip(CircleShape).clickable(enabled = !state.busy) { - controller.navigate(page) - }.padding(vertical = 8.dp).testTag("nav-${page.name}"), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Glyph(page, if (state.page == page) palette.primary else palette.onSurfaceVariant) - Text( - page.title, - fontSize = 11.sp, - color = if (state.page == - page - ) { - palette.primary - } else { - palette.onSurfaceVariant - }, - ) - } - } - } - } - }, - ) { padding -> - Column(Modifier.padding(padding).fillMaxSize()) { - Row( - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - if (state.listId == - null - ) { - state.page.title - } else { - state.listName + + Box(Modifier.fillMaxSize().background(colors.background)) { + if (state.user == null) PwaBrandField(Modifier.fillMaxSize()) + Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing).testTag("native-screen")) { + if (state.server.isEmpty() || state.user == null) { + NativeAuthentication( + state, needsLocalNetwork, requestLocalNetwork, controller::connect, controller::login, + controller::verify, controller::cancelTwoFactor, { forget = true }, + { + info = + "SSO wird im nativen Client noch nicht unterstützt. Bitte die direkte Yuvomi-Passwortanmeldung verwenden." + }, + ) + } else { + Box(Modifier.fillMaxSize()) { + Column( + Modifier.fillMaxSize().onGloballyPositioned { contentOrigin = it.positionInRoot() } + .drawWithContent { + layer.record { this@drawWithContent.drawContent() } + drawLayer(layer) }, - fontSize = 34.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = controller::refresh, enabled = !state.busy) { Text("Aktualisieren") } - } - if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) - state.error?.let { ErrorCard(it, controller::dismissError) } - when (state.page) { - NativePage.HOME -> DashboardScreen(state, controller::navigate) - NativePage.MORE -> MoreScreen(state, { controller.logout() }, { confirmForget = true }) - else -> { - val module = state.page.name.lowercase() - val write = state.access(module) == "write" && !state.busy - Row(Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) { - if (state.listId != - null - ) { - TextButton(onClick = { - controller.navigate(NativePage.SHOPPING) - }, enabled = !state.busy) { Text("Alle Listen") } - } - Spacer(Modifier.weight(1f)) - if (write) { - Button(colors = nativeButtonColors(), onClick = { - editor = null - editing = true - }) { - Text( - if (state.page == - NativePage.SHOPPING && - state.listId == null - ) { - "Neue Liste" - } else { - "Hinzufügen" - }, - ) - } - } - } - NativeCollection( + ) { + if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth().height(2.dp)) + state.error?.let { PwaError(it, controller::dismissError) } + when (state.page) { + NativePage.HOME -> NativeDashboard( + state, + controller::navigate, + { customize = true }, + ) + NativePage.TASKS, NativePage.NOTES, NativePage.SHOPPING -> NativeCollection( state, - write, onEdit = { editor = it + creatingList = false editing = true }, - onTask = controller::completeTask, - onPin = controller::pinNote, - onItem = controller::checkItem, + onTask = controller::completeTask, onPin = controller::pinNote, onItem = controller::checkItem, onList = { controller.openList(it.getLong("id"), it.text("name")) }, - onRemove = { remove = it }, + onNewList = { create(NativePage.SHOPPING, list = true) }, + onRemove = { remove = it }, onRefresh = controller::refresh, ) + NativePage.MORE -> Unit } } + PwaBottomNavigation( + state, + layer, + contentOrigin, + onNavigate = { if (it == NativePage.MORE) more = true else controller.navigate(it) }, + onCreate = { + if (state.page == NativePage.HOME) { + createMenu = true + } else { + create(state.page, list = state.page == NativePage.SHOPPING && state.listId == null) + } + }, + modifier = Modifier.align(Alignment.BottomCenter), + ) } } } if (editing) { - EntryEditor(state, editor, onDismiss = { editing = false }) { title, detail, priority, date -> + NativeEntryEditor(state, editor, creatingList, { if (!state.busy) editing = false }) { title, detail, priority, date -> when (state.page) { NativePage.TASKS -> controller.saveTask(editor?.getLong("id"), title, detail, priority, date) NativePage.NOTES -> controller.saveNote(editor?.getLong("id"), title, detail) - NativePage.SHOPPING -> controller.saveShopping(editor?.getLong("id"), title, detail) + NativePage.SHOPPING -> if (creatingList) { + controller.createShoppingList(title) + } else { + controller.saveShopping(editor?.getLong("id"), title, detail) + } else -> Unit } submitted = true } } - if (confirmForget) { - AlertDialog( - onDismissRequest = { confirmForget = false }, - title = { Text("Server entfernen?") }, - text = { - Text( - "Die lokale native Sitzung und geladene Inhalte werden gelöscht. " + - "Eine entfernte Sitzung wird dadurch nicht widerrufen; melde dich möglichst zuerst ab.", + if (more) { + ModalBottomSheet( + onDismissRequest = { more = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = colors.raised, + shape = RoundedCornerShape(topStart = 26.dp, topEnd = 26.dp), + ) { + NativeMore( + state, + { + more = false + controller.navigate(it) + }, + { + more = false + controller.logout() + }, + { + more = false + forget = true + }, + ) + } + } + if (createMenu) { + ModalBottomSheet( + onDismissRequest = { createMenu = false }, + containerColor = colors.raised, + shape = RoundedCornerShape(topStart = 26.dp, topEnd = 26.dp), + ) { + Column(Modifier.padding(horizontal = 16.dp).padding(bottom = 24.dp)) { + Text("Neu", style = PwaType.section, modifier = Modifier.padding(bottom = 12.dp)) + listOf( + NativePage.TASKS to "Neue Aufgabe", + NativePage.NOTES to "Neue Notiz", + NativePage.SHOPPING to "Neue Einkaufsliste", ) + .filter { state.access(it.first.name.lowercase()) == "write" }.forEach { (page, label) -> + PwaMenuRow(page.pwaGlyph(), label, { + create(page, page == NativePage.SHOPPING) + }, tint = colors.module(page)) + } + } + } + } + if (customize) { + NativeDashboardOptions(state, { customize = false }) { + customize = false + controller.saveDashboardVisibility(it) + } + } + if (forget) { + AlertDialog( + onDismissRequest = { forget = false }, + shape = RoundedCornerShape(26.dp), + title = { Text("Server entfernen?", style = PwaType.section) }, + text = { + Text("Die lokale Sitzung wird gelöscht. Melde dich möglichst zuerst ab, um sie auch auf dem Server zu widerrufen.") }, confirmButton = { - TextButton(onClick = { - confirmForget = false + PwaTextAction("Lokal entfernen", { + forget = false controller.forgetLocalServer() - }, enabled = !state.busy) { Text("Lokal entfernen") } + }, enabled = !state.busy, color = colors.danger) }, - dismissButton = { TextButton(onClick = { confirmForget = false }) { Text("Abbrechen") } }, + dismissButton = { PwaTextAction("Abbrechen", { forget = false }) }, ) } remove?.let { path -> AlertDialog( onDismissRequest = { remove = null }, - title = { Text("Eintrag löschen?") }, + shape = RoundedCornerShape(26.dp), + title = { Text("Eintrag löschen?", style = PwaType.section) }, text = { Text("Der Eintrag wird auf deinem Server gelöscht. Diese Aktion kann nicht zurückgenommen werden.") }, confirmButton = { - TextButton(onClick = { + PwaTextAction("Löschen", { remove = null controller.remove(path) - }) { Text("Löschen") } + }, color = colors.danger) }, - dismissButton = { TextButton(onClick = { remove = null }) { Text("Abbrechen") } }, + dismissButton = { PwaTextAction("Abbrechen", { remove = null }) }, ) } info?.let { message -> - AlertDialog(onDismissRequest = { - info = null - }, title = { Text("Native Anmeldung") }, text = { Text(message) }, confirmButton = { - TextButton(onClick = { - info = - null - }) { Text("Verstanden") } - }) + AlertDialog( + onDismissRequest = { info = null }, + shape = RoundedCornerShape(26.dp), + title = { Text("Nativer Client", style = PwaType.section) }, + text = { Text(message) }, + confirmButton = { PwaTextAction("Verstanden", { info = null }) }, + ) } } } } @Composable -private fun AuthenticationScreen( +private fun NativeAuthentication( state: NativeState, needsNetwork: Boolean, requestNetwork: () -> Unit, @@ -319,541 +292,133 @@ private fun AuthenticationScreen( forget: () -> Unit, sso: () -> Unit, ) { + val colors = LocalPwaPalette.current val keyboard = LocalSoftwareKeyboardController.current var address by remember { mutableStateOf("") } var username by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var name by remember { mutableStateOf("") } var code by remember { mutableStateOf("") } - var invalidAddress by remember { mutableStateOf(false) } + var invalid by remember { mutableStateOf(false) } + LaunchedEffect(state.server) { + username = "" + password = "" + code = "" + name = "" + } Column( Modifier.fillMaxSize().imePadding().verticalScroll(rememberScrollState()).padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Surface(shape = RoundedCornerShape(26.dp), modifier = Modifier.widthIn(max = 412.dp).fillMaxWidth()) { - Column( - Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BrandMark() - Text(state.appName, fontSize = 34.sp, fontWeight = FontWeight.Bold) - Text( - if (state.server.isEmpty()) { - "Dein Zuhause. Dein Server." - } else if (state.twoFactor) { - "Bestätige deine Anmeldung" - } else { - "Alles für euren Alltag an einem Ort." - }, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + PwaCard( + Modifier.widthIn(max = 412.dp).fillMaxWidth().testTag("auth-panel") + .border(1.dp, Color.White.copy(alpha = if (colors.dark) .12f else .65f), RoundedCornerShape(26.dp)), + color = (if (colors.dark) Color(0xFF2D2A27) else Color(0xFFFBFAF7)).copy(alpha = if (colors.dark) .88f else .86f), + radius = 26.dp, + ) { + Column(Modifier.padding(horizontal = 17.dp).padding(top = 25.dp, bottom = 16.dp)) { + Column(Modifier.fillMaxWidth().padding(bottom = 24.dp), horizontalAlignment = Alignment.CenterHorizontally) { + PwaBrandMark(Modifier.padding(bottom = 22.dp)) + Text(state.appName, style = PwaType.largeTitle, color = colors.text, textAlign = TextAlign.Center) + Spacer(Modifier.height(8.dp)) + Text( + if (state.server.isEmpty()) { + "Dein Zuhause. Dein Server." + } else if (state.twoFactor) { + "Bestätige deine Anmeldung." + } else { + "Willkommen zurück bei deinem Familienplaner." + }, + style = PwaType.field.copy(lineHeight = 25.6.sp), + color = colors.secondary, + textAlign = TextAlign.Center, + ) + } if (state.server.isEmpty()) { - NativeField(address, { + PwaField(address, { address = it - invalidAddress = false - }, "Server-Adresse", Modifier.testTag("server-url"), keyboard = KeyboardType.Uri) - if (invalidAddress) { + invalid = false + }, "Server-Adresse", Modifier.testTag("server-url"), keyboard = KeyboardType.Uri, placeholder = "https://") + if (invalid) { Text( "Bitte eine gültige HTTPS-Adresse ohne Unterpfad eingeben.", - color = MaterialTheme.colorScheme.error, + style = PwaType.caption, + color = colors.danger, ) } - Text( - "HTTPS · eigener Server · keine zentrale Cloud", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (needsNetwork) TextButton(onClick = requestNetwork) { Text("Zugriff auf private Netzwerke erlauben") } - Button( - colors = nativeButtonColors(), - modifier = Modifier.fillMaxWidth().height(48.dp).testTag("connect"), - enabled = - !state.busy && address.isNotBlank(), - onClick = { - keyboard?.hide() - if (runCatching { ServerAddress.parse(address) }.isSuccess) connect(address) else invalidAddress = true - }, - ) { Text("Verbinden") } - } else if (state.twoFactor) { - NativeField(code, { code = it }, "Code oder Wiederherstellungscode", Modifier.testTag("two-factor-code")) - Button(colors = nativeButtonColors(), onClick = { + Spacer(Modifier.height(16.dp)) + PwaButton("Verbinden", { keyboard?.hide() - verify(code) - code = "" - }, enabled = !state.busy && code.isNotBlank(), modifier = Modifier.fillMaxWidth()) { Text("Bestätigen") } - TextButton(onClick = cancelTwoFactor, enabled = !state.busy) { Text("Zurück zur Anmeldung") } + if (runCatching { ServerAddress.parse(address) }.isSuccess) connect(address) else invalid = true + }, Modifier.fillMaxWidth().testTag("connect"), !state.busy && address.isNotBlank()) + if (needsNetwork) PwaTextAction("Zugriff auf private Netzwerke erlauben", requestNetwork) + } else if (state.twoFactor) { + PwaField(code, { code = it }, "Code oder Wiederherstellungscode", Modifier.testTag("two-factor-code")) + Spacer(Modifier.height(16.dp)) + PwaButton( + "Bestätigen", + { + keyboard?.hide() + val value = code + code = "" + verify(value) + }, + Modifier.fillMaxWidth(), + !state.busy && code.isNotBlank(), + ) + PwaTextAction("Zurück zur Anmeldung", cancelTwoFactor, enabled = !state.busy) } else { - if (state.setup) NativeField(name, { name = it }, "Anzeigename") + if (state.setup) { + PwaField(name, { name = it }, "Anzeigename") + Spacer(Modifier.height(16.dp)) + } if (state.passwordAllowed || state.setup) { - NativeField(username, { username = it }, "Benutzername", Modifier.testTag("username")) - NativeField(password, { password = it }, "Passwort", Modifier.testTag("password"), password = true) - Button( - colors = nativeButtonColors(), - onClick = { - keyboard?.hide() - val submitted = password - password = "" - login(username, submitted, name) - }, - enabled = - !state.busy && username.isNotBlank() && password.isNotEmpty(), - modifier = Modifier.fillMaxWidth().height(48.dp).testTag("login"), - ) { - Text(if (state.setup) "Haushalt einrichten" else "Anmelden") - } + PwaField(username, { username = it }, "Benutzername", Modifier.testTag("username")) + Spacer(Modifier.height(16.dp)) + PwaField(password, { password = it }, "Passwort", Modifier.testTag("password"), password = true) + Spacer(Modifier.height(16.dp)) + PwaButton(if (state.setup) "Haushalt einrichten" else "Anmelden", { + keyboard?.hide() + val value = password + password = "" + login(username, value, name) + }, Modifier.fillMaxWidth().testTag("login"), !state.busy && username.isNotBlank() && password.isNotEmpty()) } - if (state.oidc) TextButton(onClick = sso) { Text("SSO – noch in Umsetzung") } - TextButton(onClick = forget, enabled = !state.busy) { Text("Anderen Server verwenden") } + if (state.oidc) PwaTextAction("SSO – noch in Umsetzung", sso, Modifier.align(Alignment.CenterHorizontally)) + } + state.error?.let { + Text(it, style = PwaType.caption, color = colors.danger, modifier = Modifier.padding(top = 12.dp).testTag("auth-error")) + } + if (state.busy) { + Box(Modifier.fillMaxWidth().padding(top = 12.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } + } + if (state.server.isNotEmpty() && !state.twoFactor) { + PwaTextAction( + "Anderen Server verwenden", + forget, + Modifier.align(Alignment.CenterHorizontally).padding(top = 2.dp), + !state.busy, + colors.tertiary, + ) + } else { + Spacer(Modifier.height(26.dp)) } - if (state.busy) CircularProgressIndicator(Modifier.size(24.dp)) - state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("auth-error")) } } } } } @Composable -private fun DashboardScreen(state: NativeState, navigate: (NativePage) -> Unit) { - val data = state.dashboard - LazyColumn( - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.testTag("native-dashboard"), +fun PwaError(message: String, dismiss: () -> Unit) { + val colors = LocalPwaPalette.current + Row( + Modifier.fillMaxWidth().background(colors.danger.copy(alpha = .08f)).padding(start = 16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - item { - Text( - "Hallo, ${state.user?.text("display_name").orEmpty().ifBlank { - state.user?.text("username").orEmpty() - }}", - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, - ) - Text( - LocalDate.now().format(DateTimeFormatter.ofPattern("EEEE, d. MMMM", Locale.GERMAN)), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (state.access("tasks") != "none" && state.widget("tasks")) { - item { - DashboardCard("Aufgaben", "${data.countText("openTaskCount")} offen", NativePage.TASKS, { navigate(NativePage.TASKS) }) { - val tasks = data.rows("urgentTasks") - if (tasks.isEmpty()) EmptyHint("Keine dringenden Aufgaben") - tasks.forEach { PreviewRow(it.text("title"), it.text("due_date")) } - } - } - } - if (state.access("calendar") != "none" && state.widget("calendar")) { - item { - DashboardCard("Kalender", "Nächste Termine", null, {}) { - val events = data.rows("upcomingEvents") - if (events.isEmpty()) EmptyHint("Keine anstehenden Termine") - events.forEach { PreviewRow(it.text("title"), it.text("start_datetime").replace('T', ' ').take(16)) } - } - } - } - if (state.access("shopping") != "none" && state.widget("shopping")) { - item { - DashboardCard("Einkauf", "${data.countText("shoppingOpenCount")} offene Artikel", NativePage.SHOPPING, { - navigate(NativePage.SHOPPING) - }) { - data.rows("shoppingLists").forEach { PreviewRow(it.text("name"), "${it.optInt("open_count")} offen") } - if (data.rows("shoppingLists").isEmpty()) EmptyHint("Alles eingekauft") - } - } - } - if (state.access("notes") != "none" && state.widget("notes")) { - item { - DashboardCard("Notizen", "Angeheftet", NativePage.NOTES, { navigate(NativePage.NOTES) }) { - val notes = data.rows("pinnedNotes") - if (notes.isEmpty()) EmptyHint("Noch keine Notizen") - notes.forEach { PreviewRow(it.text("title").ifBlank { "Notiz" }, it.text("content").take(140)) } - } - } - } - } -} - -@Composable -private fun DashboardCard( - title: String, - subtitle: String, - page: NativePage?, - onClick: () -> Unit, - content: @Composable ColumnScope.() -> Unit, -) { - Surface(shape = PageShape, modifier = Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (page != null) { - Glyph(page, MaterialTheme.colorScheme.primary) - Spacer(Modifier.width(8.dp)) - } - Column(Modifier.weight(1f)) { - Text(title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) - Text(subtitle, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - if (page != null) TextButton(onClick = onClick) { Text("Alle") } - } - content() - } - } -} - -@Composable -private fun NativeCollection( - state: NativeState, - write: Boolean, - onEdit: (JSONObject) -> Unit, - onTask: (JSONObject) -> Unit, - onPin: (JSONObject) -> Unit, - onItem: (JSONObject) -> Unit, - onList: (JSONObject) -> Unit, - onRemove: (String) -> Unit, -) { - var query by remember(state.page, state.listId) { mutableStateOf("") } - var status by remember { mutableStateOf("open") } - Column(Modifier.fillMaxSize()) { - NativeField(query, { query = it }, "Suchen", Modifier.padding(16.dp)) - if (state.page == NativePage.TASKS) { - Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf("open" to "Offen", "done" to "Erledigt", "all" to "Alle").forEach { (value, label) -> - FilterChip( - selected = - status == value, - onClick = { status = value }, - label = { Text(label) }, - ) - } - } - } - val rows = state.rows.filter { row -> - val title = if (state.page == NativePage.SHOPPING) row.text("name") else row.text("title") - val matches = query.isBlank() || (title + row.text("content") + row.text("description")).contains(query, true) - matches && - ( - state.page != NativePage.TASKS || status == "all" || - if (status == "done") row.text("status") == "done" else row.text("status") != "done" - ) - } - LazyColumn( - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (rows.isEmpty() && - !state.busy - ) { - item { EmptyHint(if (query.isBlank()) "Hier gibt es noch keine Einträge." else "Keine passenden Einträge.") } - } - items(rows, key = { it.getLong("id") }) { row -> - Surface(shape = PageShape, modifier = Modifier.fillMaxWidth()) { - Column(Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - val checked = if (state.page == - NativePage.TASKS - ) { - row.text("status") == "done" - } else { - row.optInt("is_checked") == 1 - } - if (state.page == NativePage.TASKS || - (state.page == NativePage.SHOPPING && state.listId != null) - ) { - Checkbox(checked, onCheckedChange = { - if (state.page == - NativePage.TASKS - ) { - onTask(row) - } else { - onItem(row) - } - }, enabled = write) - } - val title = if (state.page == NativePage.SHOPPING) row.text("name") else row.text("title").ifBlank { "Notiz" } - Column( - Modifier.weight(1f).clip(RoundedCornerShape(8.dp)).clickable( - enabled = - !state.busy && (write || state.page == NativePage.SHOPPING && state.listId == null), - ) { - if (state.page == NativePage.SHOPPING && state.listId == null) onList(row) else onEdit(row) - }.padding(4.dp), - ) { - Text( - title, - fontWeight = FontWeight.SemiBold, - textDecoration = if (checked) TextDecoration.LineThrough else null, - ) - val detail = when (state.page) { - NativePage.TASKS -> listOf( - row.text("category"), - row.text("due_date"), - row.text("priority").takeUnless { - it == - "none" - }.orEmpty(), - ).filter { it.isNotBlank() }.joinToString(" · ") - NativePage.NOTES -> row.text("content") - NativePage.SHOPPING -> if (state.listId != - null - ) { - listOf(row.text("quantity"), row.text("category")).filter { - it.isNotBlank() - }.joinToString(" · ") - } else { - "${row.optInt("item_total")} Artikel" - } - else -> "" - } - if (detail.isNotBlank()) Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 14.sp) - } - if (state.page == NativePage.NOTES && - write - ) { - TextButton(onClick = { onPin(row) }) { Text(if (row.optInt("pinned") == 1) "Lösen" else "Anheften") } - } - } - if (write) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = { - onRemove( - when (state.page) { - NativePage.TASKS -> "/tasks/${row.getLong("id")}" - NativePage.NOTES -> "/notes/${row.getLong("id")}" - else -> if (state.listId == - null - ) { - "/shopping/${row.getLong("id")}" - } else { - "/shopping/items/${row.getLong("id")}" - } - }, - ) - }) { Text("Löschen", color = MaterialTheme.colorScheme.error) } - } - } - } - } - } - } - } -} - -@Composable -private fun EntryEditor(state: NativeState, entry: JSONObject?, onDismiss: () -> Unit, save: (String, String, String, String) -> Unit) { - 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 - AlertDialog(onDismissRequest = { if (!state.busy) onDismiss() }, title = { - Text( - if (entry == - null - ) { - "Hinzufügen" - } else { - "Bearbeiten" - }, - ) - }, text = { - Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { - state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - NativeField(title, { title = it }, if (state.page == NativePage.SHOPPING) "Name" else "Titel", Modifier.testTag("entry-title")) - if (state.page != NativePage.SHOPPING || - state.listId != null - ) { - NativeField( - 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") - Column { - listOf( - "none" to "Keine", - "low" to "Niedrig", - "medium" to "Mittel", - "high" to "Hoch", - "urgent" to "Dringend", - ).forEach { (value, label) -> - FilterChip(selected = priority == value, onClick = { priority = value }, label = { Text(label) }) - } - } - NativeField(date, { date = it }, "Fällig am (JJJJ-MM-TT)") - if (!validDate) Text("Bitte ein gültiges Datum eingeben.", color = MaterialTheme.colorScheme.error) - } - } - }, confirmButton = { - TextButton( - onClick = { - keyboard?.hide() - save(title, detail, priority, date) - }, - enabled = - validDate && !state.busy && (title.isNotBlank() || state.page == NativePage.NOTES && detail.isNotBlank()) && - (state.page != NativePage.NOTES || detail.isNotBlank()), - modifier = Modifier.testTag("entry-save"), - ) { Text("Speichern") } - }, dismissButton = { TextButton(onClick = onDismiss, enabled = !state.busy) { Text("Abbrechen") } }) -} - -@Composable -private fun MoreScreen(state: NativeState, logout: () -> Unit, forget: () -> Unit) { - LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - item { - DashboardCard("Dein Haushalt", state.appName, null, {}) { - Text(state.user?.text("display_name").orEmpty()) - Text(state.server, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - item { - DashboardCard("Konto", "Native Sitzung", null, {}) { - TextButton(onClick = logout, enabled = !state.busy) { Text("Abmelden") } - TextButton(onClick = forget, enabled = !state.busy) { Text("Server und lokale Sitzung entfernen") } - } - } - item { - DashboardCard("Nativer Neubau", "Noch keine vollständige PWA-Parität", null, {}) { - Text( - "Kalender, Schichtplan, Geburtstage, Küche, Vorrat, Budget, Kontakte, Dokumente, Inventar, " + - "Haushaltshilfe, Abfall, Belohnungen, Gesundheit und Einstellungen werden separat nativ nachgebaut. " + - "Es gibt keinen Browser-Fallback.", - ) - } - } - } -} - -@Composable -private fun nativeButtonColors() = ButtonDefaults.buttonColors( - containerColor = if (isSystemInDarkTheme()) Color(0xFF7C3AED) else Violet, - contentColor = Color.White, -) - -@Composable -private fun NativeField( - value: String, - change: (String) -> Unit, - label: String, - modifier: Modifier = Modifier, - password: Boolean = false, - keyboard: KeyboardType = KeyboardType.Text, - singleLine: Boolean = true, -) { - OutlinedTextField( - value, - change, - label = { Text(label) }, - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(10.dp), - singleLine = singleLine, - visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None, - keyboardOptions = KeyboardOptions(keyboardType = if (password) KeyboardType.Password else keyboard), - ) -} - -@Composable -private fun EmptyHint(text: String) { - Text(text, Modifier.padding(vertical = 20.dp), color = MaterialTheme.colorScheme.onSurfaceVariant) -} - -@Composable -private fun PreviewRow(title: String, detail: String) { - Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - Text(title, fontWeight = FontWeight.Medium) - if (detail.isNotBlank()) Text(detail, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) - } -} - -@Composable -private fun ErrorCard(text: String, dismiss: () -> Unit) { - Surface(Modifier.padding(horizontal = 16.dp).fillMaxWidth(), shape = PageShape, color = MaterialTheme.colorScheme.errorContainer) { - Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Text(text, Modifier.weight(1f)) - TextButton(onClick = dismiss) { Text("Schließen") } - } - } -} - -@Composable -private fun BrandMark() { - Canvas(Modifier.size(64.dp).semantics { contentDescription = "Yuvomi" }) { - drawRoundRect(Brush.linearGradient(listOf(Color(0xFF8B5CF6), Violet)), 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 -private fun Glyph(page: NativePage, color: Color) { - Canvas(Modifier.size(24.dp)) { - val scale = size.width / 24 - val path = Path() - when (page) { - NativePage.HOME -> { - path.moveTo(3 * scale, 10 * scale) - path.lineTo(12 * scale, 3 * scale) - path.lineTo(21 * scale, 10 * scale) - path.lineTo(21 * scale, 21 * scale) - path.lineTo(3 * scale, 21 * scale) - path.close() - } - NativePage.TASKS -> { - path.moveTo(4 * scale, 12 * scale) - path.lineTo(10 * scale, 18 * scale) - path.lineTo(21 * scale, 5 * scale) - } - NativePage.SHOPPING -> { - path.moveTo(2 * scale, 3 * scale) - path.lineTo(5 * scale, 3 * scale) - path.lineTo(8 * scale, 16 * scale) - path.lineTo(20 * scale, 16 * scale) - path.lineTo(22 * scale, 7 * scale) - path.lineTo(6 * scale, 7 * scale) - drawCircle( - color, - 1.5f * scale, - Offset(9 * scale, 21 * scale), - ) - drawCircle(color, 1.5f * scale, Offset(19 * scale, 21 * scale)) - } - NativePage.NOTES -> { - path.moveTo(5 * scale, 3 * scale) - path.lineTo(19 * scale, 3 * scale) - path.lineTo(19 * scale, 21 * scale) - path.lineTo(5 * scale, 21 * scale) - path.close() - path.moveTo(9 * scale, 8 * scale) - path.lineTo(15 * scale, 8 * scale) - path.moveTo(9 * scale, 13 * scale) - path.lineTo(15 * scale, 13 * scale) - } - NativePage.MORE -> { - listOf(5f, 12f, 19f).forEach { drawCircle(color, 1.5f * scale, Offset(it * scale, 12 * scale)) } - } - } - drawPath(path, color, style = Stroke(width = 1.8f * scale)) + Text(message, Modifier.weight(1f), style = PwaType.caption, color = colors.danger) + PwaIconButton(PwaGlyph.CLOSE, "Fehler schließen", dismiss, outlined = false, tint = colors.danger) } } diff --git a/android/app/src/main/java/community/yuvomi/mobile/NativeController.kt b/android/app/src/main/java/community/yuvomi/mobile/NativeController.kt index afcb15d..1fa5146 100644 --- a/android/app/src/main/java/community/yuvomi/mobile/NativeController.kt +++ b/android/app/src/main/java/community/yuvomi/mobile/NativeController.kt @@ -31,6 +31,8 @@ data class NativeState( val resetAllowed: Boolean = false, val page: NativePage = NativePage.HOME, val dashboard: JSONObject = JSONObject(), + val preferences: JSONObject = JSONObject(), + val shoppingLists: List = emptyList(), val rows: List = emptyList(), val listId: Long? = null, val listName: String = "", @@ -77,6 +79,8 @@ class NativeController(application: Application) : AndroidViewModel(application) user = null, permissions = JSONObject(), dashboard = JSONObject(), + preferences = JSONObject(), + shoppingLists = emptyList(), rows = emptyList(), page = NativePage.HOME, listId = null, @@ -168,6 +172,12 @@ class NativeController(application: Application) : AndroidViewModel(application) requireNotNull(client).requireSessionCookie() val user = response.getJSONObject("user") val permissions = response.getJSONObject("permissions") + val preferences = try { + requireNotNull(client).request("/preferences").optJSONObject("data") ?: JSONObject() + } catch (error: Exception) { + if (error is NativeApiError && error.status == 401) throw error + JSONObject() + } val signedIn = previous.copy( user = user, permissions = permissions, @@ -175,6 +185,8 @@ class NativeController(application: Application) : AndroidViewModel(application) twoFactor = false, page = NativePage.HOME, dashboard = JSONObject(), + preferences = preferences, + shoppingLists = emptyList(), rows = emptyList(), ) return try { @@ -196,7 +208,16 @@ class NativeController(application: Application) : AndroidViewModel(application) NativePage.HOME -> previous.copy(dashboard = api.request("/dashboard")) NativePage.TASKS -> previous.copy(rows = api.request("/tasks").rows()) NativePage.NOTES -> previous.copy(rows = api.request("/notes").rows()) - NativePage.SHOPPING -> previous.copy(rows = api.request(previous.listId?.let { "/shopping/$it/items" } ?: "/shopping").rows()) + NativePage.SHOPPING -> { + val lists = api.request("/shopping").rows() + val selected = lists.firstOrNull { it.optLong("id") == previous.listId } ?: lists.firstOrNull() + previous.copy( + shoppingLists = lists, + listId = selected?.getLong("id"), + listName = selected?.text("name").orEmpty(), + rows = selected?.let { api.request("/shopping/${it.getLong("id")}/items").rows() }.orEmpty(), + ) + } NativePage.MORE -> previous } } @@ -270,6 +291,21 @@ class NativeController(application: Application) : AndroidViewModel(application) load(previous) } + fun createShoppingList(name: String) = perform { previous -> + val list = requireNotNull(client).request("/shopping", "POST", JSONObject().put("name", name.trim())).getJSONObject("data") + load(previous.copy(listId = list.getLong("id"))) + } + + fun saveDashboardVisibility(visible: Set) = perform { previous -> + // A failed preference read must never overwrite unknown stored widgets. + check(previous.preferences.has("dashboard_widgets")) + val editable = pwaSupportedWidgets.filter { previous.moduleVisible(it) && previous.widget(it) }.toSet() + val widgets = pwaWidgetVisibility(previous.preferences, visible, editable) + requireNotNull(client).request("/preferences", "PUT", JSONObject().put("dashboard_widgets", widgets)) + val preferences = requireNotNull(client).request("/preferences").getJSONObject("data") + load(previous.copy(preferences = preferences)) + } + fun checkItem(item: JSONObject) = perform { previous -> requireNotNull(client).request( "/shopping/items/${item.getLong("id")}", @@ -309,6 +345,8 @@ class NativeController(application: Application) : AndroidViewModel(application) user = null, permissions = JSONObject(), dashboard = JSONObject(), + preferences = JSONObject(), + shoppingLists = emptyList(), rows = emptyList(), listId = null, page = NativePage.HOME, diff --git a/android/app/src/main/java/community/yuvomi/mobile/NativeForms.kt b/android/app/src/main/java/community/yuvomi/mobile/NativeForms.kt new file mode 100644 index 0000000..80b43ff --- /dev/null +++ b/android/app/src/main/java/community/yuvomi/mobile/NativeForms.kt @@ -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()), + ) + } + } + } +} diff --git a/android/app/src/main/java/community/yuvomi/mobile/NativePages.kt b/android/app/src/main/java/community/yuvomi/mobile/NativePages.kt new file mode 100644 index 0000000..b22cdc5 --- /dev/null +++ b/android/app/src/main/java/community/yuvomi/mobile/NativePages.kt @@ -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, + ) + } + } +} diff --git a/android/app/src/main/java/community/yuvomi/mobile/PwaDesign.kt b/android/app/src/main/java/community/yuvomi/mobile/PwaDesign.kt new file mode 100644 index 0000000..9052d2a --- /dev/null +++ b/android/app/src/main/java/community/yuvomi/mobile/PwaDesign.kt @@ -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) +} diff --git a/android/app/src/main/java/community/yuvomi/mobile/PwaIcons.kt b/android/app/src/main/java/community/yuvomi/mobile/PwaIcons.kt new file mode 100644 index 0000000..32c3e43 --- /dev/null +++ b/android/app/src/main/java/community/yuvomi/mobile/PwaIcons.kt @@ -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 = emptyList(), + val rects: List = emptyList(), + val circles: List = 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 +} diff --git a/android/app/src/main/java/community/yuvomi/mobile/PwaPresentation.kt b/android/app/src/main/java/community/yuvomi/mobile/PwaPresentation.kt new file mode 100644 index 0000000..60c9e33 --- /dev/null +++ b/android/app/src/main/java/community/yuvomi/mobile/PwaPresentation.kt @@ -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 { + 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, editable: Set = 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 { + 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 diff --git a/android/app/src/main/java/community/yuvomi/mobile/PwaShell.kt b/android/app/src/main/java/community/yuvomi/mobile/PwaShell.kt new file mode 100644 index 0000000..155ad1f --- /dev/null +++ b/android/app/src/main/java/community/yuvomi/mobile/PwaShell.kt @@ -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) -> 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"), + ) + } + } +} diff --git a/android/app/src/test/java/community/yuvomi/mobile/PwaPresentationTest.kt b/android/app/src/test/java/community/yuvomi/mobile/PwaPresentationTest.kt new file mode 100644 index 0000000..bf6c9e4 --- /dev/null +++ b/android/app/src/test/java/community/yuvomi/mobile/PwaPresentationTest.kt @@ -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(), 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")) + } +} diff --git a/docs/continuation.md b/docs/continuation.md index 93d5fd5..393c636 100644 --- a/docs/continuation.md +++ b/docs/continuation.md @@ -13,6 +13,39 @@ Read [native parity](native-parity.md), [architecture](architecture.md), and shopping slices exist, but **no module has accepted full visual/functional parity**. The remaining pages/settings/auth/file/offline/notification work is explicit. +### Android design follow-up: 0.2.2 native preview / code 4 + +The user confirmed the login fix and requested the PWA design 1:1. The Android +slice now uses PWA-derived native primitives in `PwaDesign.kt`, `PwaIcons.kt`, +`PwaShell.kt`, `NativePages.kt` and `NativeForms.kt`, instead of the first generic +Material layout. `PwaPresentation.kt` handles greeting/timezone/config filtering; +the controller reads personal preferences and auto-selects the first shopping list. +The confirmed cookie/session transport was not changed. + +Read [design comparison](native-design.md) before making visual claims. External +Playwright 1.55.1/Chromium 140 tooling captures the actual unmodified PWA; the +emulator exports ten light/dark native views with viewport metadata. Match the +360 × 592 content viewport and Android variable Roboto, not the system-bar-inclusive +root. The earlier clipped tab labels were caused by per-tab circular clipping. +The apparent second white capsule segment occurred where subsequent dashboard +content remained visible beneath the translucent navigation; inspect actual +matched captures rather than treating any visible card edge as a second bar. + +Reference generation uses `/tmp/opencode/yuvomi-visual-tools/node_modules/playwright`, +`/tmp/opencode/yuvomi-visual-tools/fonts.conf` and persistent browsers at +`/home/neste/yuvomi-tools/playwright-browsers`. The temporary font config points to +the emulator's variable `/system/fonts/Roboto-Regular.ttf`; it includes all weights. +Set `PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64` on this Ubuntu 26 host. +Reference output is ignored under `artifacts/visual/pwa-360x592`; native screenshots +are in the external build's `app/outputs/native-screenshots`. The side-by-side +compositor is tooling only; screenshots are evidence, not automated parity approval. + +iOS is unchanged. No module has accepted full functional/visual parity, the rest +of the inventory remains open, and signing stays paused. Continue refinement from +actual comparison images, including non-default font sizes/devices and native +module functionality; do not declare the whole user request finished from this +Android slice alone. + ### Android login follow-up: 0.2.1 native preview / code 3 A user reported immediate HTTP-401 login failure although the same direct password diff --git a/docs/images/dashboard-dark-comparison.png b/docs/images/dashboard-dark-comparison.png new file mode 100644 index 0000000..bdb8e60 Binary files /dev/null and b/docs/images/dashboard-dark-comparison.png differ diff --git a/docs/images/dashboard-light-comparison.png b/docs/images/dashboard-light-comparison.png new file mode 100644 index 0000000..c739e9f Binary files /dev/null and b/docs/images/dashboard-light-comparison.png differ diff --git a/docs/images/notes-dark-comparison.png b/docs/images/notes-dark-comparison.png new file mode 100644 index 0000000..7c54941 Binary files /dev/null and b/docs/images/notes-dark-comparison.png differ diff --git a/docs/images/tasks-light-comparison.png b/docs/images/tasks-light-comparison.png new file mode 100644 index 0000000..a4d610d Binary files /dev/null and b/docs/images/tasks-light-comparison.png differ diff --git a/docs/native-design.md b/docs/native-design.md new file mode 100644 index 0000000..0af260c --- /dev/null +++ b/docs/native-design.md @@ -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) diff --git a/docs/native-parity.md b/docs/native-parity.md index 30368aa..9d2f45a 100644 --- a/docs/native-parity.md +++ b/docs/native-parity.md @@ -17,7 +17,11 @@ Use these states honestly: - **Slice**: real native UI/API interaction exists, with the listed limitations. - **Pending**: not implemented. No fake screen, raw-JSON editor or WebView fallback. - **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 diff --git a/docs/validation.md b/docs/validation.md index 97cea1e..b23d0c1 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -8,6 +8,35 @@ tests are in `androidTestNative` / `androidTestNativeIntegration`, JVM native po tests, and Swift production contracts/transport tests. Archived WebView tests are not included in the native suites or counted toward native completion. +## Android 0.2.2 / code 4: PWA-derived design pass + +Local API-35 validation after the design pass: + +| Check | Observed result | +| --- | --- | +| JVM policy/presentation tests | 23 passed, zero failures/errors/skips | +| Native Compose UI/security/presentation tests | 9 passed, zero failures/errors/skips | +| Real upstream, default non-Secure cookie over HTTPS | 10 passed, zero failures/errors/skips | +| Real upstream, Secure cookie | 10 passed, zero failures/errors/skips | +| Android lint and packages | Both lint variants; debug and unsigned release APK/AAB passed | +| Screenshot evidence | Ten light/dark native views, viewport metadata, ten actual-PWA references and ten matched-size side-by-side images | +| Script checks | 9 screenshot-collection, 8 JUnit-verifier and 14 runner/probe regressions passed; repository/Python/Node/workflow checks passed | +| Dependency scan | OSV listed no advisories for 266 locked/direct Maven coordinates/plugins | + +The first ordinary device-test attempt ran **zero tests** because a leftover +CI-signed 0.2.1 preview had a different debug certificate. The verifier rejected +that run despite Gradle's successful exit. AGP cleanup removed the package; the +clean rerun executed all nine tests. Both complete integration harnesses then +exited successfully and retained the rejected-password/successful-retry regression. + +Logs: `/home/neste/yuvomi-design-build-final.log`, +`/home/neste/yuvomi-design-cookie-final.log`, +`/home/neste/yuvomi-design-secure-final.log`. +The [design comparison](native-design.md) includes reviewed synthetic images and +documents the exact 360 × 592 content viewport and intentional fixture scope. +**This is not accepted 1:1 parity.** iOS was not changed or newly validated. +Remote KVM/CoreSimulator prerequisites and production signing remain open. + ## Android 0.2.1 / code 3: reproduced login regression The user reported immediate login failure despite a working direct browser login. diff --git a/scripts/capture-pwa-reference.mjs b/scripts/capture-pwa-reference.mjs new file mode 100644 index 0000000..9ffb44a --- /dev/null +++ b/scripts/capture-pwa-reference.mjs @@ -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(); +} diff --git a/scripts/capture-pwa-reference.py b/scripts/capture-pwa-reference.py new file mode 100644 index 0000000..1a1fffb --- /dev/null +++ b/scripts/capture-pwa-reference.py @@ -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) diff --git a/scripts/compare-native-pwa.mjs b/scripts/compare-native-pwa.mjs new file mode 100644 index 0000000..b59daa8 --- /dev/null +++ b/scripts/compare-native-pwa.mjs @@ -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(` + +
PWA 2.78.0 · ${name} · ${theme}
+
Android nativ · ${name} · ${theme} · keine 1:1-Abnahme
`); + 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.`); diff --git a/scripts/integration-server.mjs b/scripts/integration-server.mjs index aeef900..ee3101f 100644 --- a/scripts/integration-server.mjs +++ b/scripts/integration-server.mjs @@ -21,10 +21,16 @@ process.env.DB_ENCRYPTION_KEY = ''; process.env.NODE_ENV = 'test'; process.env.LOG_LEVEL = 'warn'; const tls = { key: fs.readFileSync(path.join(directory, 'key.pem')), cert: fs.readFileSync(path.join(directory, 'cert.pem')) }; -const idp = await startIdentityProvider(tls); -process.env.OIDC_ISSUER = idp.issuer; -process.env.OIDC_CLIENT_ID = idp.clientId; -process.env.OIDC_CLIENT_SECRET = idp.secret; +const idp = process.env.YUVOMI_TEST_DISABLE_OIDC === 'true' ? null : await startIdentityProvider(tls); +if (idp) { + process.env.OIDC_ISSUER = idp.issuer; + process.env.OIDC_CLIENT_ID = idp.clientId; + process.env.OIDC_CLIENT_SECRET = idp.secret; +} else { + delete process.env.OIDC_ISSUER; + delete process.env.OIDC_CLIENT_ID; + delete process.env.OIDC_CLIENT_SECRET; +} const { server } = await import(pathToFileURL(path.join(upstream, 'server/index.js'))); if (!server.listening) await once(server, 'listening'); let networkAvailable = true; @@ -50,7 +56,9 @@ const proxy = https.createServer(tls, (request, response) => { }); proxy.listen(0, '127.0.0.1'); await once(proxy, 'listening'); -process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`; -idp.setRedirect(process.env.OIDC_REDIRECT_URI); -fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp.port })); -process.on('SIGTERM', () => { idp.close(); proxy.close(); server.close(); process.exit(0); }); +if (idp) { + process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`; + idp.setRedirect(process.env.OIDC_REDIRECT_URI); +} +fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp?.port ?? null })); +process.on('SIGTERM', () => { idp?.close(); proxy.close(); server.close(); process.exit(0); }); diff --git a/scripts/test-integration-evidence.py b/scripts/test-integration-evidence.py index 3a077bc..3520050 100644 --- a/scripts/test-integration-evidence.py +++ b/scripts/test-integration-evidence.py @@ -2,6 +2,7 @@ """Regressions for AGP screenshot output collection, not visual parity tests.""" import base64 import importlib.util +import json from pathlib import Path import tempfile import unittest @@ -57,6 +58,42 @@ class ScreenshotEvidenceTests(unittest.TestCase): self.assertFalse((self.screenshots / image.name).exists()) self.assertEqual(unrelated.read_text(), "preserve") + def gallery(self): + parent = self.fixture().parent + for name in HARNESS.SCREENSHOTS: + (parent / name).write_bytes(PNG) + (parent / "viewport.json").write_text(json.dumps({"widthPx": 720, "heightPx": 1184, "density": 2})) + return parent + + def test_collects_complete_allowlisted_gallery(self): + parent = self.gallery() + (parent / "private.txt").write_text("not exported") + HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True) + self.assertEqual({p.name for p in self.screenshots.iterdir()}, set(HARNESS.VISUAL_OUTPUTS)) + + def test_partial_gallery_is_not_exported(self): + parent = self.gallery() + (parent / "notes-dark.png").unlink() + with self.assertRaisesRegex(AssertionError, "Missing or ambiguous"): + HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True) + self.assertFalse(self.screenshots.exists()) + + def test_clears_every_known_gallery_file_but_preserves_unrelated(self): + parent = self.gallery() + HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True) + (parent / "other.png").write_bytes(PNG) + HARNESS.prepare_android_screenshots(self.output) + self.assertEqual([p.name for p in parent.iterdir()], ["other.png"]) + self.assertEqual(list(self.screenshots.iterdir()), []) + + def test_rejects_gallery_split_across_devices(self): + parent = self.gallery() + other = parent.with_name("second") + other.mkdir() + (parent / "notes-dark.png").rename(other / "notes-dark.png") + with self.assertRaisesRegex(AssertionError, "Mixed-device"): + HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True) + if __name__ == "__main__": unittest.main() diff --git a/scripts/test-integration.py b/scripts/test-integration.py index b3da39d..4d21f02 100644 --- a/scripts/test-integration.py +++ b/scripts/test-integration.py @@ -20,25 +20,41 @@ import urllib.request ROOT = Path(__file__).resolve().parents[1] REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d" +SCREENSHOTS = ("native-dashboard.png",) + tuple( + f"{page}-{theme}.png" for page in ("login", "dashboard", "tasks", "notes", "shopping") for theme in ("light", "dark")) +VISUAL_OUTPUTS = SCREENSHOTS + ("viewport.json",) def prepare_android_screenshots(app_output): additional = app_output / "connected_android_test_additional_output/integrationAndroidTest" screenshots = app_output / "native-screenshots" # Never accept a previous run's screenshot as current evidence. - for stale in additional.rglob("native-dashboard.png"): - stale.unlink() - (screenshots / "native-dashboard.png").unlink(missing_ok=True) + for name in VISUAL_OUTPUTS: + for stale in additional.rglob(name): + stale.unlink() + (screenshots / name).unlink(missing_ok=True) return additional, screenshots -def collect_native_screenshot(additional, screenshots): - captures = list(additional.rglob("native-dashboard.png")) - assert len(captures) == 1, "Missing or ambiguous native fixture screenshot" - capture = captures[0].read_bytes() - assert capture.startswith(b"\x89PNG\r\n\x1a\n"), "Invalid native fixture screenshot" +def collect_native_screenshot(additional, screenshots, gallery=False): + names = VISUAL_OUTPUTS if gallery else ("native-dashboard.png",) + validated = {} + parents = set() + for name in names: + captures = list(additional.rglob(name)) + assert len(captures) == 1, f"Missing or ambiguous native fixture screenshot: {name}" + capture = captures[0].read_bytes() + if name.endswith(".png"): + assert capture.startswith(b"\x89PNG\r\n\x1a\n"), "Invalid native fixture screenshot" + else: + viewport = json.loads(capture) + assert viewport["widthPx"] > 0 and viewport["heightPx"] > 0 and viewport["density"] > 0 + parents.add(captures[0].parent) + validated[name] = capture + assert len(parents) == 1, "Mixed-device native gallery evidence" screenshots.mkdir(parents=True, exist_ok=True) - (screenshots / "native-dashboard.png").write_bytes(capture) + for name, capture in validated.items(): + (screenshots / name).write_bytes(capture) def run(upstream, android, cookie_without_secure=False): @@ -152,8 +168,8 @@ def run(upstream, android, cookie_without_secure=False): raise RuntimeError("Android integration tests failed. Inspect the Gradle test report.") reports = app_output / "androidTest-results/connected/integration" subprocess.run(["python3", str(ROOT / "scripts/verify-test-results.py"), - str(reports), "--minimum", "7"], check=True) - collect_native_screenshot(additional, screenshots) + str(reports), "--minimum", "10"], check=True) + collect_native_screenshot(additional, screenshots, gallery=True) finally: subprocess.run([str(adb), "reverse", "--remove", f"tcp:{port}"], check=False) subprocess.run([str(adb), "reverse", "--remove", f"tcp:{ready['idpPort']}"], check=False)