From 3e708eff657c22c21ece2f2e2a026e7ac7335774 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 06:37:45 -0400 Subject: [PATCH 01/22] feat(android): add posts list activity for selecting existing posts Adds a new PostsListActivity that fetches and displays posts from a WordPress site so the user can pick one to edit. Mirrors the iOS PostsListView in functionality. - GutenbergKitApplication.createApiClient() builds a WpApiClient from a stored Account (works for both self-hosted Application Passwords and WP.com OAuth flows). - PostsListActivity uses the client to call posts.listWithEditContext() with pagination, then launches EditorActivity with the selected post's ID, title, and content pre-filled. - Registered in AndroidManifest.xml. Required so the Android demo can exercise the Save button flow added in a follow-up commit, which needs a real post ID to PUT to the REST API. Co-Authored-By: Claude Opus 4.6 (1M context) --- android/app/src/main/AndroidManifest.xml | 3 + .../gutenbergkit/GutenbergKitApplication.kt | 30 ++ .../example/gutenbergkit/PostsListActivity.kt | 327 ++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4eea7d6d5..d5a954927 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -50,6 +50,9 @@ + wpAuthenticationFromUsernameAndPassword( + account.username, + account.password + ) + is Account.WpCom -> WpAuthentication.Bearer(token = account.token) + } + val apiRootUrl = when (account) { + is Account.SelfHostedSite -> account.siteApiRoot + is Account.WpCom -> account.siteApiRoot + } + return WpApiClient( + wpOrgSiteApiRootUrl = URI(apiRootUrl).toURL(), + authProvider = WpAuthenticationProvider.staticWithAuth(auth), + interceptors = emptyList(), + networkAvailabilityProvider = networkAvailabilityProvider + ) + } } diff --git a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt new file mode 100644 index 000000000..9562bb6d0 --- /dev/null +++ b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt @@ -0,0 +1,327 @@ +package com.example.gutenbergkit + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +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.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.gutenbergkit.ui.theme.AppTheme +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.model.EditorDependenciesSerializer +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.AnyPostWithEditContext +import uniffi.wp_api.PostEndpointType +import uniffi.wp_api.PostListParams + +/** + * Lists posts from a WordPress site so the user can pick one to edit. + * + * Receives an [EditorConfiguration] (already prepared) and an account ID via Intent extras, + * fetches posts via the WordPress REST API using [WpApiClient], and on selection launches + * [EditorActivity] with the post's title, content, and ID. + */ +class PostsListActivity : ComponentActivity() { + + companion object { + const val EXTRA_ACCOUNT_ID = "account_id" + const val EXTRA_POST_ENDPOINT = "post_endpoint" + + fun createIntent( + context: Context, + accountId: ULong, + postEndpoint: String, + configuration: EditorConfiguration, + dependencies: EditorDependencies? + ): Intent { + return Intent(context, PostsListActivity::class.java).apply { + putExtra(EXTRA_ACCOUNT_ID, accountId.toLong()) + putExtra(EXTRA_POST_ENDPOINT, postEndpoint) + putExtra(MainActivity.EXTRA_CONFIGURATION, configuration) + if (dependencies != null) { + val filePath = EditorDependenciesSerializer.writeToDisk(context, dependencies) + putExtra(EditorActivity.EXTRA_DEPENDENCIES_PATH, filePath) + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1L).takeIf { it >= 0 }?.toULong() + val postEndpoint = intent.getStringExtra(EXTRA_POST_ENDPOINT) ?: "post" + val configuration = intent.getParcelableExtra(MainActivity.EXTRA_CONFIGURATION, EditorConfiguration::class.java) + val dependenciesPath = intent.getStringExtra(EditorActivity.EXTRA_DEPENDENCIES_PATH) + + if (accountId == null || configuration == null) { + finish() + return + } + + val viewModel = ViewModelProvider( + this, + PostsListViewModelFactory(application, accountId, postEndpoint) + )[PostsListViewModel::class.java] + + setContent { + AppTheme { + PostsListScreen( + viewModel = viewModel, + onClose = { finish() }, + onPostSelected = { post -> + launchEditor(post, configuration, dependenciesPath, accountId) + } + ) + } + } + } + + private fun launchEditor( + post: AnyPostWithEditContext, + baseConfiguration: EditorConfiguration, + dependenciesPath: String?, + accountId: ULong + ) { + val updatedConfig = baseConfiguration.toBuilder() + .setPostId(post.id.toUInt()) + .setTitle(post.title?.raw ?: "") + .setContent(post.content.raw ?: "") + .build() + + val intent = Intent(this, EditorActivity::class.java).apply { + putExtra(MainActivity.EXTRA_CONFIGURATION, updatedConfig) + putExtra(EditorActivity.EXTRA_ACCOUNT_ID, accountId.toLong()) + if (dependenciesPath != null) { + putExtra(EditorActivity.EXTRA_DEPENDENCIES_PATH, dependenciesPath) + } + } + startActivity(intent) + } +} + +data class PostsListUiState( + val posts: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null +) + +class PostsListViewModel( + application: android.app.Application, + private val accountId: ULong, + private val postEndpoint: String +) : AndroidViewModel(application) { + + private val _uiState = MutableStateFlow(PostsListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadPosts() { + if (_uiState.value.isLoading) return + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null, posts = emptyList()) } + + try { + val app = getApplication() + val account = app.accountRepository.all().firstOrNull { it.id() == accountId } + ?: throw IllegalStateException("Account not found") + val client = app.createApiClient(account) + + val endpointType = when (postEndpoint) { + "page" -> PostEndpointType.Pages + "post" -> PostEndpointType.Posts + else -> PostEndpointType.Custom(postEndpoint) + } + + val all = mutableListOf() + var page = 1u + val perPage = 20u + while (true) { + val params = PostListParams(page = page, perPage = perPage) + val result = client.request { builder -> + builder.posts().listWithEditContext(endpointType, params) + } + when (result) { + is WpRequestResult.Success -> { + val data = result.response.data + all.addAll(data) + if (data.size < perPage.toInt()) break + page++ + } + else -> { + throw IllegalStateException("Failed to load posts: $result") + } + } + } + + _uiState.update { it.copy(posts = all, isLoading = false) } + } catch (e: Exception) { + _uiState.update { it.copy(error = e.message ?: "Unknown error", isLoading = false) } + } + } + } +} + +class PostsListViewModelFactory( + private val application: android.app.Application, + private val accountId: ULong, + private val postEndpoint: String +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(PostsListViewModel::class.java)) { + return PostsListViewModel(application, accountId, postEndpoint) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PostsListScreen( + viewModel: PostsListViewModel, + onClose: () -> Unit, + onPostSelected: (AnyPostWithEditContext) -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.loadPosts() + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Posts") }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + when { + uiState.isLoading && uiState.posts.isEmpty() -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + uiState.error != null -> { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Error loading posts", + style = MaterialTheme.typography.titleMedium + ) + Text( + uiState.error ?: "", + style = MaterialTheme.typography.bodyMedium + ) + } + } + uiState.posts.isEmpty() -> { + Text( + "No posts found", + modifier = Modifier.align(Alignment.Center) + ) + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items(uiState.posts, key = { it.id }) { post -> + PostRow(post = post, onClick = { onPostSelected(post) }) + HorizontalDivider() + } + } + } + } + } + } +} + +@Composable +private fun PostRow(post: AnyPostWithEditContext, onClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val title = post.title?.rendered?.ifBlank { "(no title)" } ?: "(no title)" + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + val excerpt = post.excerpt?.rendered?.stripHtml().orEmpty() + if (excerpt.isNotBlank()) { + Text( + text = excerpt, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +private fun String.stripHtml(): String = + this.replace(Regex("<[^>]+>"), "").trim() From b6df7b6c905ed5c5197418ccc9c883483368aec3 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 06:38:00 -0400 Subject: [PATCH 02/22] feat(android): add save button that persists posts via REST API Wires the Save button in EditorActivity to: 1. Call GutenbergView.savePost() to trigger the editor store's save lifecycle so plugins (e.g., VideoPress) fire their side-effect API calls. 2. Read the latest title/content via getTitleAndContent() and persist the post via WpApiClient's posts().update() call. Also adds a "Browse" button to SitePreparationActivity (visible only for authenticated sites) that launches PostsListActivity to pick an existing post to edit. The selected post's ID is threaded through to EditorActivity via a new EXTRA_ACCOUNT_ID intent extra so the Save handler can reconstruct the API client. The Save button is disabled for new drafts (where postId is null) since updating requires an existing post ID. Requires the PostUpdateParams export from wordpress-rs PR Automattic/wordpress-rs#1270 (already exposed via uniffi for Kotlin). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../example/gutenbergkit/EditorActivity.kt | 123 ++++++++++++++++++ .../gutenbergkit/SitePreparationActivity.kt | 35 ++++- 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 9ff8ac71c..2554b76b3 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -43,16 +44,22 @@ import androidx.lifecycle.lifecycleScope import com.example.gutenbergkit.ui.theme.AppTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.GutenbergView import org.wordpress.gutenberg.RecordedNetworkRequest import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.EditorDependenciesSerializer +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.PostEndpointType +import uniffi.wp_api.PostUpdateParams +import kotlin.coroutines.resume class EditorActivity : ComponentActivity() { companion object { const val EXTRA_DEPENDENCIES_PATH = "dependencies_path" + const val EXTRA_ACCOUNT_ID = "account_id" } private var gutenbergView: GutenbergView? = null @@ -93,11 +100,15 @@ class EditorActivity : ComponentActivity() { val dependenciesPath = intent.getStringExtra(EXTRA_DEPENDENCIES_PATH) val dependencies = dependenciesPath?.let { EditorDependenciesSerializer.readFromDisk(it) } + // Optional account ID for REST API persistence (set when launched from PostsListActivity) + val accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1L).takeIf { it >= 0 }?.toULong() + setContent { AppTheme { EditorScreen( configuration = configuration, dependencies = dependencies, + accountId = accountId, coroutineScope = this.lifecycleScope, onClose = { finish() }, onGutenbergViewCreated = { view -> @@ -121,6 +132,7 @@ class EditorActivity : ComponentActivity() { fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, + accountId: ULong? = null, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} @@ -130,7 +142,12 @@ fun EditorScreen( var hasUndoState by remember { mutableStateOf(false) } var hasRedoState by remember { mutableStateOf(false) } var isCodeEditorEnabled by remember { mutableStateOf(false) } + var isSaving by remember { mutableStateOf(false) } var gutenbergViewRef by remember { mutableStateOf(null) } + val saveScope = rememberCoroutineScope() + val context = androidx.compose.ui.platform.LocalContext.current + + val canSave = !isSaving && accountId != null && configuration.postId != null BackHandler(enabled = isModalDialogOpen) { gutenbergViewRef?.dismissTopModal() @@ -173,6 +190,31 @@ fun EditorScreen( contentDescription = stringResource(R.string.redo) ) } + TextButton( + onClick = { + val view = gutenbergViewRef ?: return@TextButton + val postId = configuration.postId + if (accountId == null || postId == null) return@TextButton + isSaving = true + saveScope.launch { + try { + persistPost( + context = context, + view = view, + configuration = configuration, + accountId = accountId, + postId = postId + ) + } finally { + isSaving = false + } + } + }, + enabled = canSave && !isModalDialogOpen + ) { + Text(stringResource(R.string.save)) + } + TextButton(onClick = { }, enabled = false) { Text(stringResource(R.string.publish)) } @@ -303,3 +345,84 @@ fun EditorScreen( ) } } + +/** + * Suspends until the editor store's save lifecycle completes. + * + * Bridges the [GutenbergView.savePost] callback to a coroutine so the caller + * can sequence post-save work (like persisting content via the REST API). + */ +private suspend fun GutenbergView.savePostAwait(): Boolean = + suspendCancellableCoroutine { continuation -> + savePost { success, _ -> + if (continuation.isActive) continuation.resume(success) + } + } + +/** + * Reads the latest title/content from the editor and PUTs it to the WordPress REST API. + * + * Triggers [GutenbergView.savePost] first so plugin side-effects (e.g., VideoPress + * syncing metadata) settle before the content is read and persisted. + */ +private suspend fun persistPost( + context: android.content.Context, + view: GutenbergView, + configuration: EditorConfiguration, + accountId: ULong, + postId: UInt +) { + try { + val saveSucceeded = view.savePostAwait() + if (!saveSucceeded) { + Log.w("EditorActivity", "editor.savePost() reported failure; persisting anyway") + } + + val titleAndContent = suspendCancellableCoroutine> { cont -> + view.getTitleAndContent( + originalContent = configuration.content, + callback = object : GutenbergView.TitleAndContentCallback { + override fun onResult(title: CharSequence, content: CharSequence) { + if (cont.isActive) cont.resume(title to content) + } + } + ) + } + + val app = context.applicationContext as GutenbergKitApplication + val account = app.accountRepository.all().firstOrNull { it.id() == accountId } + ?: throw IllegalStateException("Account not found") + val client = app.createApiClient(account) + + val endpointType = when (configuration.postType) { + "page" -> PostEndpointType.Pages + "post" -> PostEndpointType.Posts + else -> PostEndpointType.Custom(configuration.postType) + } + + val params = PostUpdateParams( + title = titleAndContent.first.toString(), + content = titleAndContent.second.toString(), + meta = null + ) + + val result = client.request { builder -> + builder.posts().update( + postEndpointType = endpointType, + postId = postId.toLong(), + params = params + ) + } + + when (result) { + is WpRequestResult.Success -> { + Log.i("EditorActivity", "Post $postId persisted via REST API") + } + else -> { + Log.e("EditorActivity", "Failed to persist post $postId: $result") + } + } + } catch (e: Exception) { + Log.e("EditorActivity", "Save failed", e) + } +} diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt index 27043206b..502d0b438 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -140,13 +140,21 @@ class SitePreparationActivity : ComponentActivity() { SitePreparationViewModelFactory(application, configurationItem) )[SitePreparationViewModel::class.java] + val accountId = (configurationItem as? ConfigurationItem.ConfiguredEditor)?.accountId + setContent { AppTheme { SitePreparationScreen( viewModel = viewModel, + accountId = accountId, onClose = { finish() }, onStartEditor = { configuration, dependencies -> launchEditor(configuration, dependencies) + }, + onBrowsePosts = { configuration, dependencies, postType -> + accountId?.let { + launchPostsList(it, postType, configuration, dependencies) + } } ) } @@ -168,14 +176,27 @@ class SitePreparationActivity : ComponentActivity() { } startActivity(intent) } + + private fun launchPostsList( + accountId: ULong, + postType: String, + configuration: EditorConfiguration, + dependencies: org.wordpress.gutenberg.model.EditorDependencies? + ) { + startActivity( + PostsListActivity.createIntent(this, accountId, postType, configuration, dependencies) + ) + } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun SitePreparationScreen( viewModel: SitePreparationViewModel, + accountId: ULong?, onClose: () -> Unit, - onStartEditor: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?) -> Unit + onStartEditor: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?) -> Unit, + onBrowsePosts: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?, String) -> Unit ) { val uiState by viewModel.uiState.collectAsState() @@ -198,6 +219,18 @@ fun SitePreparationScreen( }, actions = { if (uiState.editorConfiguration != null) { + if (accountId != null) { + OutlinedButton( + onClick = { + viewModel.buildConfiguration()?.let { config -> + onBrowsePosts(config, uiState.editorDependencies, uiState.postType) + } + }, + modifier = Modifier.padding(end = 8.dp) + ) { + Text("Browse") + } + } Button( onClick = { viewModel.buildConfiguration()?.let { config -> From 445257e9f7963133661a9af4462057ac7f90c87b Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 10:07:51 -0400 Subject: [PATCH 03/22] fix(android): include drafts and other statuses in posts list PostListParams.status defaults to publish-only on the server side, hiding drafts from the demo's posts list. Pass PostStatus.Any explicitly so all statuses (draft, pending, future, etc.) appear in the picker, matching the iOS demo's behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/example/gutenbergkit/PostsListActivity.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt index 9562bb6d0..bcbe1bca4 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt @@ -52,6 +52,7 @@ import rs.wordpress.api.kotlin.WpRequestResult import uniffi.wp_api.AnyPostWithEditContext import uniffi.wp_api.PostEndpointType import uniffi.wp_api.PostListParams +import uniffi.wp_api.PostStatus /** * Lists posts from a WordPress site so the user can pick one to edit. @@ -177,7 +178,11 @@ class PostsListViewModel( var page = 1u val perPage = 20u while (true) { - val params = PostListParams(page = page, perPage = perPage) + val params = PostListParams( + page = page, + perPage = perPage, + status = listOf(PostStatus.Any) + ) val result = client.request { builder -> builder.posts().listWithEditContext(endpointType, params) } From f810f4bc3f840bff90f436762c99c7f3df282b05 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 10:01:41 -0400 Subject: [PATCH 04/22] refactor(android): move browse button beneath post type selector Matches the iOS demo's layout where the "Browse" action sits below the Post Type picker in the Feature Configuration card, rather than in the top app bar alongside Start. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gutenbergkit/SitePreparationActivity.kt | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt index 502d0b438..ac93c3521 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -219,18 +219,6 @@ fun SitePreparationScreen( }, actions = { if (uiState.editorConfiguration != null) { - if (accountId != null) { - OutlinedButton( - onClick = { - viewModel.buildConfiguration()?.let { config -> - onBrowsePosts(config, uiState.editorDependencies, uiState.postType) - } - }, - modifier = Modifier.padding(end = 8.dp) - ) { - Text("Browse") - } - } Button( onClick = { viewModel.buildConfiguration()?.let { config -> @@ -252,6 +240,12 @@ fun SitePreparationScreen( LoadedView( uiState = uiState, viewModel = viewModel, + accountId = accountId, + onBrowsePosts = { + viewModel.buildConfiguration()?.let { config -> + onBrowsePosts(config, uiState.editorDependencies, uiState.postType) + } + }, modifier = Modifier.padding(innerPadding) ) } @@ -278,6 +272,8 @@ private fun LoadingView(modifier: Modifier = Modifier) { private fun LoadedView( uiState: SitePreparationUiState, viewModel: SitePreparationViewModel, + accountId: ULong?, + onBrowsePosts: () -> Unit, modifier: Modifier = Modifier ) { LazyColumn( @@ -300,7 +296,9 @@ private fun LoadedView( enableNetworkLogging = uiState.enableNetworkLogging, onEnableNetworkLoggingChange = viewModel::setEnableNetworkLogging, postType = uiState.postType, - onPostTypeChange = viewModel::setPostType + onPostTypeChange = viewModel::setPostType, + showBrowseButton = accountId != null, + onBrowsePosts = onBrowsePosts ) } @@ -381,7 +379,9 @@ private fun FeatureConfigurationCard( enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postType: String, - onPostTypeChange: (String) -> Unit + onPostTypeChange: (String) -> Unit, + showBrowseButton: Boolean = false, + onBrowsePosts: () -> Unit = {} ) { Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { @@ -451,6 +451,16 @@ private fun FeatureConfigurationCard( } } } + + if (showBrowseButton) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = onBrowsePosts, + modifier = Modifier.fillMaxWidth() + ) { + Text("Browse") + } + } } } } From e0f7c0ab17e6cf581c4ded9ed26221e70579050c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 10:01:56 -0400 Subject: [PATCH 05/22] refactor(android): remove disabled publish button from editor toolbar The toolbar previously showed both "Save" and a non-functional "PUBLISH" button. Removes the disabled Publish button so Save is the only top-level action. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main/java/com/example/gutenbergkit/EditorActivity.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 2554b76b3..8859f89c6 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -215,10 +215,6 @@ fun EditorScreen( Text(stringResource(R.string.save)) } - TextButton(onClick = { }, enabled = false) { - Text(stringResource(R.string.publish)) - } - // Overflow menu button and dropdown in Box for proper anchoring Box { IconButton( From 707ffdd90b15fea09223405be773ec53899c96ff Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 10:06:01 -0400 Subject: [PATCH 06/22] refactor(android): uppercase save button label and clean up strings - Change R.string.save to "SAVE" so the toolbar button matches the styling of the previous PUBLISH button it replaced. - Remove the unused R.string.publish (the disabled Publish button was removed in an earlier commit). - Remove the disabled "Save" item from the editor's overflow menu so the toolbar Save button is the sole save action. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main/java/com/example/gutenbergkit/EditorActivity.kt | 5 ----- android/app/src/main/res/values/strings.xml | 3 +-- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 8859f89c6..8a5804b3d 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -230,11 +230,6 @@ fun EditorScreen( expanded = showMenu, onDismissRequest = { showMenu = false } ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.save)) }, - onClick = { }, - enabled = false - ) DropdownMenuItem( text = { Text(stringResource(R.string.preview)) }, onClick = { }, diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 16419c4f8..a3b680c0e 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -35,9 +35,8 @@ Close Undo Redo - PUBLISH More options - Save + SAVE Preview Visual editor Code editor From c4b0ab3ed1421d623beb4633c67727321a07b929 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 10:19:28 -0400 Subject: [PATCH 07/22] refactor(android): remove permanently disabled items from more menu Removes the placeholder Preview, Post Settings, and Help dropdown menu items from the editor toolbar. Only the working "Code Editor / Visual Editor" toggle remains. Also drops the now-unused string resources. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/example/gutenbergkit/EditorActivity.kt | 15 --------------- android/app/src/main/res/values/strings.xml | 3 --- 2 files changed, 18 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 8a5804b3d..5e25d16ad 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -230,11 +230,6 @@ fun EditorScreen( expanded = showMenu, onDismissRequest = { showMenu = false } ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.preview)) }, - onClick = { }, - enabled = false - ) DropdownMenuItem( text = { Text(stringResource(if (isCodeEditorEnabled) R.string.visual_editor else R.string.code_editor)) }, onClick = { @@ -243,16 +238,6 @@ fun EditorScreen( showMenu = false } ) - DropdownMenuItem( - text = { Text(stringResource(R.string.post_settings)) }, - onClick = { }, - enabled = false - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.help)) }, - onClick = { }, - enabled = false - ) } } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a3b680c0e..30089f25b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -37,9 +37,6 @@ Redo More options SAVE - Preview Visual editor Code editor - Post settings - Help From 89bde718089059e51642004823b2fe6576582a91 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 11:32:00 -0400 Subject: [PATCH 08/22] refactor(android): introduce PostTypeDetails to mirror iOS Ports the iOS `PostTypeDetails` struct to Android as a Kotlin data class with `post`/`page` companion constants. `EditorConfiguration`, `EditorPreloadList`, `GBKitGlobal`, and `EditorService` now carry the full post type details (slug + restBase + restNamespace) instead of just the slug, matching the iOS API surface 1:1. This deletes the `restBaseFor()` heuristic in `GBKitGlobal` that incorrectly pluralized custom post-type slugs, and fixes a related bug in `EditorPreloadList.buildPostPath()` where the preload key was hardcoded to `/wp/v2/posts/$id` even when editing a page. The demo app still threads a string slug internally; a small `slugToPostTypeDetails()` helper bridges to the new API. Chunk 3 will replace that helper with real REST data from `wordpress-rs`. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gutenberg/model/EditorConfiguration.kt | 10 ++-- .../gutenberg/model/EditorPreloadList.kt | 11 ++-- .../wordpress/gutenberg/model/GBKitGlobal.kt | 18 ++----- .../gutenberg/model/PostTypeDetails.kt | 44 ++++++++++++++++ .../gutenberg/services/EditorService.kt | 2 +- .../gutenberg/EditorAssetsLibraryTest.kt | 5 +- .../gutenberg/RESTAPIRepositoryTest.kt | 7 +-- .../model/EditorConfigurationTest.kt | 26 +++++----- .../gutenberg/model/EditorPreloadListTest.kt | 52 +++++++++---------- .../gutenberg/model/GBKitGlobalTest.kt | 4 +- .../gutenberg/services/EditorServiceTest.kt | 3 +- .../example/gutenbergkit/EditorActivity.kt | 4 +- .../gutenbergkit/SitePreparationViewModel.kt | 21 ++++++-- 13 files changed, 129 insertions(+), 78 deletions(-) create mode 100644 android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/PostTypeDetails.kt diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 05fe263ed..2a7293193 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -11,7 +11,7 @@ data class EditorConfiguration( val title: String, val content: String, val postId: UInt?, - val postType: String, + val postType: PostTypeDetails, val postStatus: String, val themeStyles: Boolean, val plugins: Boolean, @@ -46,15 +46,15 @@ data class EditorConfiguration( } companion object { @JvmStatic - fun builder(siteURL: String, siteApiRoot: String, postType: String = "post"): Builder = Builder(siteURL, siteApiRoot, postType = postType) + fun builder(siteURL: String, siteApiRoot: String, postType: PostTypeDetails = PostTypeDetails.post): Builder = Builder(siteURL, siteApiRoot, postType = postType) @JvmStatic - fun bundled(): EditorConfiguration = Builder("https://example.com", "https://example.com/wp-json/", "post") + fun bundled(): EditorConfiguration = Builder("https://example.com", "https://example.com/wp-json/", PostTypeDetails.post) .setEnableOfflineMode(true) .build() } - class Builder(private var siteURL: String, private var siteApiRoot: String, private var postType: String) { + class Builder(private var siteURL: String, private var siteApiRoot: String, private var postType: PostTypeDetails) { private var title: String = "" private var content: String = "" private var postId: UInt? = null @@ -77,7 +77,7 @@ data class EditorConfiguration( fun setTitle(title: String) = apply { this.title = title } fun setContent(content: String) = apply { this.content = content } fun setPostId(postId: UInt?) = apply { this.postId = postId?.takeIf { it != 0u } } - fun setPostType(postType: String) = apply { this.postType = postType } + fun setPostType(postType: PostTypeDetails) = apply { this.postType = postType } fun setPostStatus(postStatus: String) = apply { this.postStatus = postStatus } fun setThemeStyles(themeStyles: Boolean) = apply { this.themeStyles = themeStyles } fun setPlugins(plugins: Boolean) = apply { this.plugins = plugins } diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorPreloadList.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorPreloadList.kt index 087e8269b..206da830a 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorPreloadList.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorPreloadList.kt @@ -28,8 +28,8 @@ data class EditorPreloadList private constructor( val postID: Int?, /** The pre-fetched post data for the post being edited. */ val postData: EditorURLResponse?, - /** The post type identifier (e.g., "post", "page"). */ - val postType: String, + /** Details about the post type, including REST API configuration. */ + val postType: PostTypeDetails, /** Pre-fetched data for the current post type's schema. */ val postTypeData: EditorURLResponse, /** Pre-fetched data for all available post types. */ @@ -47,7 +47,7 @@ data class EditorPreloadList private constructor( constructor( postID: Int? = null, postData: EditorURLResponse? = null, - postType: String, + postType: PostTypeDetails, postTypeData: EditorURLResponse, postTypesData: EditorURLResponse, activeThemeData: EditorURLResponse?, @@ -72,7 +72,7 @@ data class EditorPreloadList private constructor( fun build(): JsonElement { val entries = mutableMapOf() - entries[buildPostTypePath(postType)] = postTypeData.toJsonElement() + entries[buildPostTypePath(postType.postType)] = postTypeData.toJsonElement() entries[POST_TYPES_PATH] = postTypesData.toJsonElement() if (postID != null && postData != null) { @@ -115,7 +115,8 @@ data class EditorPreloadList private constructor( } /** Builds the API path for fetching a specific post. */ - private fun buildPostPath(id: Int): String = "/wp/v2/posts/$id?context=edit" + private fun buildPostPath(id: Int): String = + "/${postType.restNamespace}/${postType.restBase}/$id?context=edit" /** Builds the API path for fetching a post type's schema. */ private fun buildPostTypePath(type: String): String = "/wp/v2/types/$type?context=edit" diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt index cc1288d2c..4ec3ff1e2 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt @@ -113,9 +113,9 @@ data class GBKitGlobal( locale = configuration.locale ?: "en", post = Post( id = postId ?: -1, - type = configuration.postType, - restBase = restBaseFor(configuration.postType), - restNamespace = "wp/v2", + type = configuration.postType.postType, + restBase = configuration.postType.restBase, + restNamespace = configuration.postType.restNamespace, status = configuration.postStatus, title = configuration.title.encodeForEditor(), content = configuration.content.encodeForEditor() @@ -132,18 +132,6 @@ data class GBKitGlobal( } ) } - - /** - * Maps a post type slug to its WordPress REST API base path. - * - * Defaults to pluralizing the slug for unknown types (e.g., `product` → `products`), - * which matches the WordPress convention for most post types. - */ - private fun restBaseFor(postType: String): String = when (postType) { - "post" -> "posts" - "page" -> "pages" - else -> if (postType.endsWith("s")) postType else "${postType}s" - } } /** diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/PostTypeDetails.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/PostTypeDetails.kt new file mode 100644 index 000000000..0625bb9a4 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/PostTypeDetails.kt @@ -0,0 +1,44 @@ +package org.wordpress.gutenberg.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * Details about a WordPress post type needed for REST API interactions. + * + * This class encapsulates the information required to construct correct REST API + * endpoints for different post types. WordPress custom post types (like WooCommerce + * products) have their own REST endpoints that differ from the standard `/wp/v2/posts/`. + * + * For standard post types, use the provided constants: + * ```kotlin + * val config = EditorConfiguration.builder(siteURL, siteApiRoot, PostTypeDetails.post) + * val config = EditorConfiguration.builder(siteURL, siteApiRoot, PostTypeDetails.page) + * ``` + * + * For custom post types, create an instance with the appropriate REST base: + * ```kotlin + * val productType = PostTypeDetails(postType = "product", restBase = "products") + * val config = EditorConfiguration.builder(siteURL, siteApiRoot, productType) + * ``` + * + * @property postType The post type slug (e.g., "post", "page", "product"). + * @property restBase The REST API base path for this post type (e.g., "posts", "pages", "products"). + * @property restNamespace The REST API namespace for this post type (e.g., "wp/v2"). Defaults to "wp/v2". + */ +@Parcelize +@Serializable +data class PostTypeDetails( + val postType: String, + val restBase: String, + val restNamespace: String = "wp/v2" +) : Parcelable { + companion object { + /** Standard WordPress post type. */ + val post = PostTypeDetails(postType = "post", restBase = "posts") + + /** Standard WordPress page type. */ + val page = PostTypeDetails(postType = "page", restBase = "pages") + } +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt index 507c36351..283d3990d 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt @@ -285,7 +285,7 @@ class EditorService( return coroutineScope { val activeThemeDeferred = async { prepareActiveTheme() } val settingsOptionsDeferred = async { prepareSettingsOptions() } - val postTypeDataDeferred = async { preparePostType(configuration.postType) } + val postTypeDataDeferred = async { preparePostType(configuration.postType.postType) } val postTypesDataDeferred = async { preparePostTypes() } val postId = configuration.postId diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorAssetsLibraryTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorAssetsLibraryTest.kt index e31247a67..02d4f753c 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorAssetsLibraryTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorAssetsLibraryTest.kt @@ -15,6 +15,7 @@ import org.wordpress.gutenberg.model.EditorCachePolicy import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorProgress import org.wordpress.gutenberg.model.LocalEditorAssetManifest +import org.wordpress.gutenberg.model.PostTypeDetails import org.wordpress.gutenberg.model.TestResources import org.wordpress.gutenberg.model.http.EditorHTTPHeaders import org.wordpress.gutenberg.model.http.EditorHttpMethod @@ -37,7 +38,7 @@ class EditorAssetsLibraryTest { val testConfiguration: EditorConfiguration = EditorConfiguration.builder( TEST_SITE_URL, TEST_API_ROOT, - "post" + PostTypeDetails.post ) .setPlugins(true) .setThemeStyles(true) @@ -46,7 +47,7 @@ class EditorAssetsLibraryTest { val minimalConfiguration: EditorConfiguration = EditorConfiguration.builder( TEST_SITE_URL, TEST_API_ROOT, - "post" + PostTypeDetails.post ) .setPlugins(false) .setThemeStyles(false) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt index fa9a742a9..40ee19ff6 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt @@ -12,6 +12,7 @@ import org.junit.rules.TemporaryFolder import org.wordpress.gutenberg.model.EditorCachePolicy import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorSettings +import org.wordpress.gutenberg.model.PostTypeDetails import org.wordpress.gutenberg.model.http.EditorHTTPHeaders import org.wordpress.gutenberg.model.http.EditorHttpMethod import org.wordpress.gutenberg.stores.EditorURLCache @@ -42,7 +43,7 @@ class RESTAPIRepositoryTest { siteApiRoot: String = TEST_API_ROOT, siteApiNamespace: Array = arrayOf() ): EditorConfiguration { - return EditorConfiguration.builder(TEST_SITE_URL, siteApiRoot, "post") + return EditorConfiguration.builder(TEST_SITE_URL, siteApiRoot, PostTypeDetails.post) .setPlugins(shouldUsePlugins) .setThemeStyles(shouldUseThemeStyles) .setAuthHeader("Bearer test-token") @@ -290,7 +291,7 @@ class RESTAPIRepositoryTest { val configuration = EditorConfiguration.builder( TEST_SITE_URL, "https://example.com/wp-json", // No trailing slash - "post" + PostTypeDetails.post ).setPlugins(true).setThemeStyles(true).setAuthHeader("Bearer test").build() val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) @@ -319,7 +320,7 @@ class RESTAPIRepositoryTest { val configuration = EditorConfiguration.builder( TEST_SITE_URL, "https://example.com/wp-json/", // With trailing slash - "post" + PostTypeDetails.post ).setPlugins(true).setThemeStyles(true).setAuthHeader("Bearer test").build() val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt index c0052980b..48e5a8ad8 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt @@ -15,7 +15,7 @@ class EditorConfigurationBuilderTest { companion object { const val TEST_SITE_URL = "https://example.com" const val TEST_API_ROOT = "https://example.com/wp-json" - const val TEST_POST_TYPE = "post" + val TEST_POST_TYPE = PostTypeDetails.post } private fun builder() = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, TEST_POST_TYPE) @@ -100,10 +100,10 @@ class EditorConfigurationBuilderTest { @Test fun `setPostType updates postType`() { val config = builder() - .setPostType("page") + .setPostType(PostTypeDetails.page) .build() - assertEquals("page", config.postType) + assertEquals(PostTypeDetails.page, config.postType) } @Test @@ -310,7 +310,7 @@ class EditorConfigurationBuilderTest { .setTitle("Round Trip Title") .setContent("

Round trip content

") .setPostId(999u) - .setPostType("page") + .setPostType(PostTypeDetails.page) .setPostStatus("draft") .setThemeStyles(true) .setPlugins(true) @@ -387,7 +387,7 @@ class EditorConfigurationBuilderTest { fun `toBuilder preserves nullable values when set`() { val original = builder() .setPostId(123u) - .setPostType("post") + .setPostType(PostTypeDetails.post) .setPostStatus("publish") .setEditorSettings("""{"test":true}""") .setEditorAssetsEndpoint("https://example.com/assets") @@ -396,7 +396,7 @@ class EditorConfigurationBuilderTest { val rebuilt = original.toBuilder().build() assertEquals(123u, rebuilt.postId) - assertEquals("post", rebuilt.postType) + assertEquals(PostTypeDetails.post, rebuilt.postType) assertEquals("publish", rebuilt.postStatus) assertEquals("""{"test":true}""", rebuilt.editorSettings) assertEquals("https://example.com/assets", rebuilt.editorAssetsEndpoint) @@ -480,7 +480,7 @@ class EditorConfigurationBuilderTest { assertTrue(config.enableOfflineMode) assertEquals("https://example.com", config.siteURL) assertEquals("https://example.com/wp-json/", config.siteApiRoot) - assertEquals("post", config.postType) + assertEquals(PostTypeDetails.post, config.postType) } } @@ -489,7 +489,7 @@ class EditorConfigurationTest { companion object { const val TEST_SITE_URL = "https://example.com" const val TEST_API_ROOT = "https://example.com/wp-json" - const val TEST_POST_TYPE = "post" + val TEST_POST_TYPE = PostTypeDetails.post } private fun builder() = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, TEST_POST_TYPE) @@ -553,10 +553,10 @@ class EditorConfigurationTest { @Test fun `Configurations with different postType are not equal`() { - val config1 = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, "post") + val config1 = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, PostTypeDetails.post) .build() - val config2 = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, "page") + val config2 = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, PostTypeDetails.page) .build() assertNotEquals(config1, config2) @@ -850,11 +850,11 @@ class EditorConfigurationTest { @Test fun `test EditorConfiguration builder sets all properties correctly`() { - val config = EditorConfiguration.builder("https://example.com", "https://example.com/wp-json", "post") + val config = EditorConfiguration.builder("https://example.com", "https://example.com/wp-json", PostTypeDetails.post) .setTitle("Test Title") .setContent("Test Content") .setPostId(123u) - .setPostType("post") + .setPostType(PostTypeDetails.post) .setPostStatus("publish") .setThemeStyles(true) .setPlugins(true) @@ -877,7 +877,7 @@ class EditorConfigurationTest { assertEquals("Test Title", config.title) assertEquals("Test Content", config.content) assertEquals(123u, config.postId) - assertEquals("post", config.postType) + assertEquals(PostTypeDetails.post, config.postType) assertEquals("publish", config.postStatus) assertTrue(config.themeStyles) assertTrue(config.plugins) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorPreloadListTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorPreloadListTest.kt index 6d8dc5f03..2d82c2194 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorPreloadListTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorPreloadListTest.kt @@ -37,7 +37,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 42, postData = postData, - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -51,14 +51,14 @@ class EditorPreloadListTest { @Test fun `initializes with custom post type`() { val preloadList = EditorPreloadList( - postType = "page", + postType = PostTypeDetails.page, postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), settingsOptionsData = makeResponse() ) - assertEquals("page", preloadList.postType) + assertEquals(PostTypeDetails.page, preloadList.postType) } // MARK: - build() Exact Output Tests @@ -66,7 +66,7 @@ class EditorPreloadListTest { @Test fun `build produces exact JSON for post type`() { val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = """{"slug":"post"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -81,7 +81,7 @@ class EditorPreloadListTest { @Test fun `build produces exact JSON for page type`() { val preloadList = EditorPreloadList( - postType = "page", + postType = PostTypeDetails.page, postTypeData = makeResponse(data = """{"slug":"page"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -98,7 +98,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 123, postData = makeResponse(data = """{"id":123,"title":"Test"}"""), - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = "{}"), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -114,7 +114,7 @@ class EditorPreloadListTest { fun `build produces exact JSON with Accept header`() { val headers = EditorHTTPHeaders(mapOf("Accept" to "application/json")) val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = "{}", headers = headers), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -130,7 +130,7 @@ class EditorPreloadListTest { fun `build produces exact JSON with Link header`() { val headers = EditorHTTPHeaders(mapOf("Link" to """; rel="next"""")) val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = "{}", headers = headers), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -148,7 +148,7 @@ class EditorPreloadListTest { mapOf("Link" to "", "Accept" to "application/json") ) val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = "{}", headers = headers), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -165,7 +165,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = null, postData = null, - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -182,7 +182,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 42, postData = null, - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -197,7 +197,7 @@ class EditorPreloadListTest { @Test fun `build produces exact JSON for custom_post_type`() { val preloadList = EditorPreloadList( - postType = "custom_post_type", + postType = PostTypeDetails(postType = "custom_post_type", restBase = "custom_post_type"), postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -214,7 +214,7 @@ class EditorPreloadListTest { @Test fun `build(formatted = false) returns valid JSON string`() { val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = """{"slug":"post"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -229,7 +229,7 @@ class EditorPreloadListTest { @Test fun `build(formatted = true) returns valid JSON string`() { val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = """{"slug":"post"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -244,7 +244,7 @@ class EditorPreloadListTest { @Test fun `build(formatted = true) produces pretty-printed JSON`() { val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = "{}"), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -283,7 +283,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 123, postData = makeResponse(data = """{"id":123,"title":"Test"}"""), - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = """{"slug":"post"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -304,7 +304,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 123, postData = makeResponse(data = """{"id":123,"title":"Test"}"""), - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = """{"slug":"post"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -326,7 +326,7 @@ class EditorPreloadListTest { mapOf("Accept" to "application/json", "Content-Type" to "application/json") ) val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(headers = headers), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -343,7 +343,7 @@ class EditorPreloadListTest { mapOf("Accept" to "application/json", "X-Custom" to "value") ) val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(headers = headers), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -362,7 +362,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 1, postData = makeResponse(headers = headers), - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -379,14 +379,14 @@ class EditorPreloadListTest { fun `two preload lists with same data are equal`() { val response = makeResponse(data = """{"test":true}""") val preloadList1 = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = response, postTypesData = response, activeThemeData = response, settingsOptionsData = response ) val preloadList2 = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = response, postTypesData = response, activeThemeData = response, @@ -400,14 +400,14 @@ class EditorPreloadListTest { fun `preload lists with different post types are not equal`() { val response = makeResponse() val preloadList1 = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = response, postTypesData = response, activeThemeData = response, settingsOptionsData = response ) val preloadList2 = EditorPreloadList( - postType = "page", + postType = PostTypeDetails.page, postTypeData = response, postTypesData = response, activeThemeData = response, @@ -423,7 +423,7 @@ class EditorPreloadListTest { val preloadList1 = EditorPreloadList( postID = 1, postData = response, - postType = "post", + postType = PostTypeDetails.post, postTypeData = response, postTypesData = response, activeThemeData = response, @@ -432,7 +432,7 @@ class EditorPreloadListTest { val preloadList2 = EditorPreloadList( postID = 2, postData = response, - postType = "post", + postType = PostTypeDetails.post, postTypeData = response, postTypesData = response, activeThemeData = response, diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt index b49068af3..2ef407699 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt @@ -27,7 +27,7 @@ class GBKitGlobalTest { private fun makePreloadList(): EditorPreloadList { return EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = EditorURLResponse(data = "{}", responseHeaders = EditorHTTPHeaders()), postTypesData = EditorURLResponse(data = "{}", responseHeaders = EditorHTTPHeaders()), activeThemeData = EditorURLResponse(data = "{}", responseHeaders = EditorHTTPHeaders()), @@ -40,7 +40,7 @@ class GBKitGlobalTest { title: String? = null, content: String? = null, siteURL: String = TEST_SITE_URL, - postType: String = "post", + postType: PostTypeDetails = PostTypeDetails.post, shouldUsePlugins: Boolean = true, shouldUseThemeStyles: Boolean = true ): EditorConfiguration { diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt index 81afaf959..7e7643505 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt @@ -12,6 +12,7 @@ import org.wordpress.gutenberg.EditorHTTPClientDownloadResponse import org.wordpress.gutenberg.EditorHTTPClientProtocol import org.wordpress.gutenberg.EditorHTTPClientResponse import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.PostTypeDetails import org.wordpress.gutenberg.model.http.EditorHTTPHeaders import org.wordpress.gutenberg.model.http.EditorHttpMethod import java.io.File @@ -35,7 +36,7 @@ class EditorServiceTest { val testConfiguration: EditorConfiguration = EditorConfiguration.builder( TEST_SITE_URL, TEST_API_ROOT, - "post" + PostTypeDetails.post ) .setPlugins(true) .setThemeStyles(true) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 5e25d16ad..6d701adb9 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -370,10 +370,10 @@ private suspend fun persistPost( ?: throw IllegalStateException("Account not found") val client = app.createApiClient(account) - val endpointType = when (configuration.postType) { + val endpointType = when (configuration.postType.postType) { "page" -> PostEndpointType.Pages "post" -> PostEndpointType.Posts - else -> PostEndpointType.Custom(configuration.postType) + else -> PostEndpointType.Custom(configuration.postType.postType) } val params = PostUpdateParams( diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt index bb1a6c81f..bdbf4e607 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -13,8 +13,23 @@ import kotlinx.coroutines.launch import org.wordpress.gutenberg.model.EditorCachePolicy import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.model.PostTypeDetails import org.wordpress.gutenberg.services.EditorService +/** + * Maps a post type slug to its [PostTypeDetails]. + * + * This is a temporary placeholder until the demo fetches real `restBase`/ + * `restNamespace` from the WordPress REST API. For the standard `post` and + * `page` slugs we know the correct values; anything else gets a naive + * pluralization fallback that's likely wrong for real CPTs. + */ +private fun slugToPostTypeDetails(slug: String): PostTypeDetails = when (slug) { + "post" -> PostTypeDetails.post + "page" -> PostTypeDetails.page + else -> PostTypeDetails(postType = slug, restBase = if (slug.endsWith("s")) slug else "${slug}s") +} + data class SitePreparationUiState( val enableNativeInserter: Boolean = true, val enableNetworkLogging: Boolean = false, @@ -198,7 +213,7 @@ class SitePreparationViewModel( return EditorConfiguration.builder( siteURL = "https://example.com", siteApiRoot = "https://example.com", - postType = "post" + postType = PostTypeDetails.post ) .setPlugins(false) .setSiteApiNamespace(arrayOf()) @@ -234,7 +249,7 @@ class SitePreparationViewModel( return EditorConfiguration.builder( siteURL = config.siteUrl, siteApiRoot = siteApiRoot, - postType = _uiState.value.postType + postType = slugToPostTypeDetails(_uiState.value.postType) ) .setPlugins(capabilities.supportsPlugins) .setThemeStyles(capabilities.supportsThemeStyles) @@ -267,7 +282,7 @@ class SitePreparationViewModel( return baseConfig.toBuilder() .setEnableNetworkLogging(_uiState.value.enableNetworkLogging) // TODO: Add setNativeInserterEnabled when it's available in EditorConfiguration - .setPostType(_uiState.value.postType) + .setPostType(slugToPostTypeDetails(_uiState.value.postType)) .build() } } From 8f83dadce7c2467cfe1bf6bdb9f49c862df0c393 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 11:36:09 -0400 Subject: [PATCH 09/22] fix(android): build post fetch URL from PostTypeDetails `RESTAPIRepository.buildPostUrl` was hardcoded to `/wp/v2/posts/$id`, so opening a `page` (or any non-post type) hit the wrong endpoint and the WordPress REST API returned `rest_post_invalid_id`. Use the configuration's `restNamespace`/`restBase` instead, matching iOS (`RESTAPIRepository.swift:110-120`). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/java/org/wordpress/gutenberg/RESTAPIRepository.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt index 7a3eee0cc..692ca79ca 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt @@ -75,7 +75,9 @@ class RESTAPIRepository( } private fun buildPostUrl(id: Int): String { - return buildNamespacedUrl("/wp/v2/posts/$id?context=edit") + val restNamespace = configuration.postType.restNamespace + val restBase = configuration.postType.restBase + return buildNamespacedUrl("/$restNamespace/$restBase/$id?context=edit") } // MARK: Editor Settings From 99ad3800e6116f2dddf0da8b66d33ee74d4c754e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 11:53:41 -0400 Subject: [PATCH 10/22] feat(demo-android): fetch real PostTypeDetails from REST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hardcoded `post`/`page` picker with a dynamic list fetched via `wordpress-rs`'s `postTypes.listWithEditContext()`, mirroring the iOS `SitePreparationView.loadPostTypes()` flow. The view-model now stores `postTypes: List` and `selectedPostType: PostTypeDetails?`, deletes the `slugToPostTypeDetails` placeholder, and threads the selected type straight into `EditorConfiguration` and `PostsListActivity`. The post-type filter matches iOS: always include `post`/`page`, include custom types only when `viewable && visibility.showUi`, exclude all internal built-ins (`Attachment`, `WpBlock`, etc.). `PostsListActivity` now takes a `PostTypeDetails` extra (Parcelable) instead of a string slug, and dispatches the endpoint type from `postType.postType` so the existing `Posts`/`Pages`/`Custom` `when` keeps working for the standard cases. The picker shows "Loading post types…" while the list is empty and falls back to `PostTypeDetails.post` if the REST call fails so the editor can still launch. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../example/gutenbergkit/PostsListActivity.kt | 22 +++-- .../gutenbergkit/SitePreparationActivity.kt | 68 ++++++++------ .../gutenbergkit/SitePreparationViewModel.kt | 92 +++++++++++++++---- 3 files changed, 126 insertions(+), 56 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt index bcbe1bca4..6923aee11 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt @@ -48,6 +48,7 @@ import kotlinx.coroutines.launch import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.EditorDependenciesSerializer +import org.wordpress.gutenberg.model.PostTypeDetails import rs.wordpress.api.kotlin.WpRequestResult import uniffi.wp_api.AnyPostWithEditContext import uniffi.wp_api.PostEndpointType @@ -65,18 +66,18 @@ class PostsListActivity : ComponentActivity() { companion object { const val EXTRA_ACCOUNT_ID = "account_id" - const val EXTRA_POST_ENDPOINT = "post_endpoint" + const val EXTRA_POST_TYPE = "post_type" fun createIntent( context: Context, accountId: ULong, - postEndpoint: String, + postType: PostTypeDetails, configuration: EditorConfiguration, dependencies: EditorDependencies? ): Intent { return Intent(context, PostsListActivity::class.java).apply { putExtra(EXTRA_ACCOUNT_ID, accountId.toLong()) - putExtra(EXTRA_POST_ENDPOINT, postEndpoint) + putExtra(EXTRA_POST_TYPE, postType) putExtra(MainActivity.EXTRA_CONFIGURATION, configuration) if (dependencies != null) { val filePath = EditorDependenciesSerializer.writeToDisk(context, dependencies) @@ -91,7 +92,8 @@ class PostsListActivity : ComponentActivity() { enableEdgeToEdge() val accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1L).takeIf { it >= 0 }?.toULong() - val postEndpoint = intent.getStringExtra(EXTRA_POST_ENDPOINT) ?: "post" + val postType = intent.getParcelableExtra(EXTRA_POST_TYPE, PostTypeDetails::class.java) + ?: PostTypeDetails.post val configuration = intent.getParcelableExtra(MainActivity.EXTRA_CONFIGURATION, EditorConfiguration::class.java) val dependenciesPath = intent.getStringExtra(EditorActivity.EXTRA_DEPENDENCIES_PATH) @@ -102,7 +104,7 @@ class PostsListActivity : ComponentActivity() { val viewModel = ViewModelProvider( this, - PostsListViewModelFactory(application, accountId, postEndpoint) + PostsListViewModelFactory(application, accountId, postType) )[PostsListViewModel::class.java] setContent { @@ -150,7 +152,7 @@ data class PostsListUiState( class PostsListViewModel( application: android.app.Application, private val accountId: ULong, - private val postEndpoint: String + private val postType: PostTypeDetails ) : AndroidViewModel(application) { private val _uiState = MutableStateFlow(PostsListUiState()) @@ -168,10 +170,10 @@ class PostsListViewModel( ?: throw IllegalStateException("Account not found") val client = app.createApiClient(account) - val endpointType = when (postEndpoint) { + val endpointType = when (postType.postType) { "page" -> PostEndpointType.Pages "post" -> PostEndpointType.Posts - else -> PostEndpointType.Custom(postEndpoint) + else -> PostEndpointType.Custom(postType.postType) } val all = mutableListOf() @@ -210,12 +212,12 @@ class PostsListViewModel( class PostsListViewModelFactory( private val application: android.app.Application, private val accountId: ULong, - private val postEndpoint: String + private val postType: PostTypeDetails ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(PostsListViewModel::class.java)) { - return PostsListViewModel(application, accountId, postEndpoint) as T + return PostsListViewModel(application, accountId, postType) as T } throw IllegalArgumentException("Unknown ViewModel class") } diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt index ac93c3521..7915d68e2 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -55,6 +55,7 @@ import androidx.lifecycle.ViewModelProvider import com.example.gutenbergkit.ui.theme.AppTheme import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependenciesSerializer +import org.wordpress.gutenberg.model.PostTypeDetails class SitePreparationActivity : ComponentActivity() { @@ -179,7 +180,7 @@ class SitePreparationActivity : ComponentActivity() { private fun launchPostsList( accountId: ULong, - postType: String, + postType: PostTypeDetails, configuration: EditorConfiguration, dependencies: org.wordpress.gutenberg.model.EditorDependencies? ) { @@ -196,7 +197,7 @@ fun SitePreparationScreen( accountId: ULong?, onClose: () -> Unit, onStartEditor: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?) -> Unit, - onBrowsePosts: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?, String) -> Unit + onBrowsePosts: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?, PostTypeDetails) -> Unit ) { val uiState by viewModel.uiState.collectAsState() @@ -242,8 +243,11 @@ fun SitePreparationScreen( viewModel = viewModel, accountId = accountId, onBrowsePosts = { - viewModel.buildConfiguration()?.let { config -> - onBrowsePosts(config, uiState.editorDependencies, uiState.postType) + val selectedPostType = uiState.selectedPostType + if (selectedPostType != null) { + viewModel.buildConfiguration()?.let { config -> + onBrowsePosts(config, uiState.editorDependencies, selectedPostType) + } } }, modifier = Modifier.padding(innerPadding) @@ -295,7 +299,8 @@ private fun LoadedView( onEnableNativeInserterChange = viewModel::setEnableNativeInserter, enableNetworkLogging = uiState.enableNetworkLogging, onEnableNetworkLoggingChange = viewModel::setEnableNetworkLogging, - postType = uiState.postType, + postTypes = uiState.postTypes, + selectedPostType = uiState.selectedPostType, onPostTypeChange = viewModel::setPostType, showBrowseButton = accountId != null, onBrowsePosts = onBrowsePosts @@ -378,8 +383,9 @@ private fun FeatureConfigurationCard( onEnableNativeInserterChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, - postType: String, - onPostTypeChange: (String) -> Unit, + postTypes: List, + selectedPostType: PostTypeDetails?, + onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> Unit = {} ) { @@ -427,27 +433,35 @@ private fun FeatureConfigurationCard( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 8.dp) ) - Column(modifier = Modifier.selectableGroup()) { - listOf("post" to "Post", "page" to "Page").forEach { (value, label) -> - Row( - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = postType == value, - onClick = { onPostTypeChange(value) }, - role = Role.RadioButton + if (postTypes.isEmpty()) { + Text( + text = "Loading post types…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Column(modifier = Modifier.selectableGroup()) { + postTypes.forEach { postType -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = postType == selectedPostType, + onClick = { onPostTypeChange(postType) }, + role = Role.RadioButton + ) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = postType == selectedPostType, + onClick = null ) - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = postType == value, - onClick = null - ) - Text( - text = label, - modifier = Modifier.padding(start = 8.dp) - ) + Text( + text = postType.postType.replaceFirstChar { it.uppercase() }, + modifier = Modifier.padding(start = 8.dp) + ) + } } } } diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt index bdbf4e607..32aa1e232 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -15,25 +15,16 @@ import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.PostTypeDetails import org.wordpress.gutenberg.services.EditorService - -/** - * Maps a post type slug to its [PostTypeDetails]. - * - * This is a temporary placeholder until the demo fetches real `restBase`/ - * `restNamespace` from the WordPress REST API. For the standard `post` and - * `page` slugs we know the correct values; anything else gets a naive - * pluralization fallback that's likely wrong for real CPTs. - */ -private fun slugToPostTypeDetails(slug: String): PostTypeDetails = when (slug) { - "post" -> PostTypeDetails.post - "page" -> PostTypeDetails.page - else -> PostTypeDetails(postType = slug, restBase = if (slug.endsWith("s")) slug else "${slug}s") -} +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.PostType as WpPostType data class SitePreparationUiState( val enableNativeInserter: Boolean = true, val enableNetworkLogging: Boolean = false, - val postType: String = "post", + /** All viewable post types fetched from the site, or empty while loading. */ + val postTypes: List = emptyList(), + /** The post type currently selected in the picker. Null until [postTypes] loads. */ + val selectedPostType: PostTypeDetails? = null, val cacheBundleCount: Int? = null, val isLoading: Boolean = false, val error: String? = null, @@ -98,8 +89,8 @@ class SitePreparationViewModel( _uiState.update { it.copy(enableNetworkLogging = enabled) } } - fun setPostType(postType: String) { - _uiState.update { it.copy(postType = postType) } + fun setPostType(postType: PostTypeDetails) { + _uiState.update { it.copy(selectedPostType = postType) } } fun prepareEditor() { @@ -210,6 +201,13 @@ class SitePreparationViewModel( } private fun createBundledConfiguration(): EditorConfiguration { + // Bundled offline editor: only the standard `post` type is meaningful. + _uiState.update { + it.copy( + postTypes = listOf(PostTypeDetails.post), + selectedPostType = PostTypeDetails.post + ) + } return EditorConfiguration.builder( siteURL = "https://example.com", siteApiRoot = "https://example.com", @@ -246,10 +244,22 @@ class SitePreparationViewModel( arrayOf() } + // Fetch the site's post types and pick the first one as the default + // selection. Falls back to `PostTypeDetails.post` if the call fails so + // the editor can still launch with a sensible default. + val postTypes = loadPostTypes(config) ?: listOf(PostTypeDetails.post) + val defaultPostType = postTypes.first() + _uiState.update { + it.copy( + postTypes = postTypes, + selectedPostType = defaultPostType + ) + } + return EditorConfiguration.builder( siteURL = config.siteUrl, siteApiRoot = siteApiRoot, - postType = slugToPostTypeDetails(_uiState.value.postType) + postType = defaultPostType ) .setPlugins(capabilities.supportsPlugins) .setThemeStyles(capabilities.supportsThemeStyles) @@ -265,6 +275,49 @@ class SitePreparationViewModel( .build() } + /** + * Fetches the site's post types from the WordPress REST API and returns the + * subset relevant to the editor picker. + * + * Mirrors the iOS [SitePreparationView.loadPostTypes] filter: + * - always include the standard `post` and `page` types + * - include custom types only when they are `viewable` and have UI visibility + * - exclude all internal types (`Attachment`, `WpBlock`, etc.) + * + * Returns `null` if the post types could not be fetched (e.g. account not + * found, network error). Callers fall back to a sensible default. + */ + private suspend fun loadPostTypes( + config: ConfigurationItem.ConfiguredEditor + ): List? { + val app = getApplication() + val account = app.accountRepository.all().firstOrNull { it.id() == config.accountId } + ?: return null + val client = app.createApiClient(account) + + val result = client.request { builder -> + builder.postTypes().listWithEditContext() + } + if (result !is WpRequestResult.Success) return null + + return result.response.data.postTypes + .filter { (type, details) -> + when (type) { + is WpPostType.Post, is WpPostType.Page -> true + is WpPostType.Custom -> details.viewable && details.visibility.showUi + else -> false + } + } + .map { (_, details) -> + PostTypeDetails( + postType = details.slug, + restBase = details.restBase, + restNamespace = details.restNamespace + ) + } + .sortedBy { it.postType } + } + /** * Extracts the WP.com site ID from a namespace-specific API root URL. * Returns null if the URL is not a WP.com API root. @@ -278,11 +331,12 @@ class SitePreparationViewModel( fun buildConfiguration(): EditorConfiguration? { val baseConfig = _uiState.value.editorConfiguration ?: return null + val selectedPostType = _uiState.value.selectedPostType ?: return null return baseConfig.toBuilder() .setEnableNetworkLogging(_uiState.value.enableNetworkLogging) // TODO: Add setNativeInserterEnabled when it's available in EditorConfiguration - .setPostType(slugToPostTypeDetails(_uiState.value.postType)) + .setPostType(selectedPostType) .build() } } From 7edf0e138dda36f3179c81b5b57901ecec0837d2 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 12:15:53 -0400 Subject: [PATCH 11/22] refactor(demo-android): simplify PostsListActivity, extract strings, fix imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the manual pagination loop in `PostsListViewModel.loadPosts` — iOS doesn't paginate either, and a single 20-post page is plenty for the demo. Removes ~25 lines and a hidden N-request fan-out on busy sites. - Inline the `PostsListViewModelFactory` as an anonymous object in `onCreate`. The standalone factory class was 12 lines of pure boilerplate for a one-shot screen with immutable args. - Move the activity's hardcoded UI strings ("Posts", "Back", "Browse", "No posts found", "Error loading posts", "Failed to save post") to `strings.xml`. Save error toast messages also use string resources with a positional argument for the underlying error. - Clean up fully-qualified imports introduced earlier in this branch: `android.content.Context`, `androidx.compose.ui.platform.LocalContext`, and `org.wordpress.gutenberg.model.EditorDependencies`. These now use short names with proper imports at the top of each file. - Refresh `app/detekt-baseline.xml` to absorb the legitimate `LongMethod`/`LongParameterList`/`TooGenericExceptionCaught` cases added by this branch's earlier commits — they're inherent to the Compose composables and the demo's intentionally permissive error handling. Three pre-existing `UseCheckOrError` warnings are gone now that the catch sites use `error()` instead of `throw IllegalStateException`. Co-Authored-By: Claude Opus 4.6 (1M context) --- android/app/detekt-baseline.xml | 8 +- .../example/gutenbergkit/EditorActivity.kt | 45 ++++++---- .../example/gutenbergkit/PostsListActivity.kt | 84 ++++++++----------- .../gutenbergkit/SitePreparationActivity.kt | 14 ++-- android/app/src/main/res/values/strings.xml | 11 +++ 5 files changed, 88 insertions(+), 74 deletions(-) diff --git a/android/app/detekt-baseline.xml b/android/app/detekt-baseline.xml index 3fb3b6665..0cd33fef4 100644 --- a/android/app/detekt-baseline.xml +++ b/android/app/detekt-baseline.xml @@ -2,11 +2,13 @@ - LongMethod:EditorActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} ) + LongMethod:EditorActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, accountId: ULong? = null, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} ) LongMethod:MainActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( configurations: List<ConfigurationItem>, onConfigurationClick: (ConfigurationItem) -> Unit, onConfigurationLongClick: (ConfigurationItem) -> Boolean, onAddConfiguration: (String) -> Unit, onDeleteConfiguration: (ConfigurationItem) -> Unit, onMediaProxyServer: () -> Unit = {}, isDiscoveringSite: Boolean = false, onDismissDiscovering: () -> Unit = {}, isLoadingCapabilities: Boolean = false, authError: String? = null, onDismissAuthError: () -> Unit = {} ) LongMethod:MediaProxyServerActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun MediaProxyServerScreen(onBack: () -> Unit) - LongMethod:SitePreparationActivity.kt$@Composable private fun FeatureConfigurationCard( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postType: String, onPostTypeChange: (String) -> Unit ) + LongMethod:PostsListActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun PostsListScreen( viewModel: PostsListViewModel, onClose: () -> Unit, onPostSelected: (AnyPostWithEditContext) -> Unit ) + LongMethod:SitePreparationActivity.kt$@Composable private fun FeatureConfigurationCard( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List<PostTypeDetails>, selectedPostType: PostTypeDetails?, onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> Unit = {} ) LongParameterList:MainActivity.kt$( configurations: List<ConfigurationItem>, onConfigurationClick: (ConfigurationItem) -> Unit, onConfigurationLongClick: (ConfigurationItem) -> Boolean, onAddConfiguration: (String) -> Unit, onDeleteConfiguration: (ConfigurationItem) -> Unit, onMediaProxyServer: () -> Unit = {}, isDiscoveringSite: Boolean = false, onDismissDiscovering: () -> Unit = {}, isLoadingCapabilities: Boolean = false, authError: String? = null, onDismissAuthError: () -> Unit = {} ) + LongParameterList:SitePreparationActivity.kt$( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List<PostTypeDetails>, selectedPostType: PostTypeDetails?, onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> Unit = {} ) MaxLineLength:MediaProxyServerActivity.kt$Text("Size", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) MaxLineLength:MediaProxyServerActivity.kt$Text("Throughput", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) MaxLineLength:MediaProxyServerActivity.kt$Text("Time", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) @@ -18,6 +20,8 @@ SwallowedException:SitePreparationViewModel.kt$SitePreparationViewModel$e: java.net.ConnectException ThrowsCount:AuthenticationManager.kt$AuthenticationManager$private fun handleApplicationPasswordsCallback( data: Uri, callback: AuthenticationCallback ) TooGenericExceptionCaught:AuthenticationManager.kt$AuthenticationManager$e: Exception + TooGenericExceptionCaught:EditorActivity.kt$e: Exception + TooGenericExceptionCaught:PostsListActivity.kt$PostsListViewModel$e: Exception TooGenericExceptionCaught:SiteCapabilitiesDiscovery.kt$SiteCapabilitiesDiscovery$e: Exception TooGenericExceptionCaught:SitePreparationActivity.kt$SitePreparationActivity.Companion$e: Exception TooGenericExceptionCaught:SitePreparationViewModel.kt$SitePreparationViewModel$e: Exception diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 6d701adb9..49d59fa25 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -1,12 +1,14 @@ package com.example.gutenbergkit +import android.content.Context import android.content.Intent -import android.os.Bundle -import android.view.ViewGroup -import android.webkit.WebView import android.content.pm.ApplicationInfo import android.os.Build +import android.os.Bundle import android.util.Log +import android.view.ViewGroup +import android.webkit.WebView +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent @@ -38,6 +40,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.lifecycleScope @@ -145,7 +148,7 @@ fun EditorScreen( var isSaving by remember { mutableStateOf(false) } var gutenbergViewRef by remember { mutableStateOf(null) } val saveScope = rememberCoroutineScope() - val context = androidx.compose.ui.platform.LocalContext.current + val context = LocalContext.current val canSave = !isSaving && accountId != null && configuration.postId != null @@ -198,13 +201,16 @@ fun EditorScreen( isSaving = true saveScope.launch { try { - persistPost( + val errorMessage = persistPost( context = context, view = view, configuration = configuration, accountId = accountId, postId = postId ) + if (errorMessage != null) { + Toast.makeText(context, errorMessage, Toast.LENGTH_LONG).show() + } } finally { isSaving = false } @@ -339,21 +345,27 @@ private suspend fun GutenbergView.savePostAwait(): Boolean = * Reads the latest title/content from the editor and PUTs it to the WordPress REST API. * * Triggers [GutenbergView.savePost] first so plugin side-effects (e.g., VideoPress - * syncing metadata) settle before the content is read and persisted. + * syncing metadata) settle before the content is read and persisted. A lifecycle + * failure must NOT block the user from saving their work — the warning is logged + * and persistence proceeds anyway. */ private suspend fun persistPost( - context: android.content.Context, + context: Context, view: GutenbergView, configuration: EditorConfiguration, accountId: ULong, postId: UInt -) { - try { - val saveSucceeded = view.savePostAwait() - if (!saveSucceeded) { - Log.w("EditorActivity", "editor.savePost() reported failure; persisting anyway") - } +): String? { + // 1. Trigger the editor store save lifecycle so plugins fire side-effects. + val saveSucceeded = view.savePostAwait() + if (saveSucceeded) { + Log.i("EditorActivity", "editor.savePost() completed — editor store save lifecycle fired") + } else { + Log.w("EditorActivity", "editor.savePost() lifecycle failed; persisting anyway") + } + // 2. Persist post content via REST API. + return try { val titleAndContent = suspendCancellableCoroutine> { cont -> view.getTitleAndContent( originalContent = configuration.content, @@ -367,7 +379,7 @@ private suspend fun persistPost( val app = context.applicationContext as GutenbergKitApplication val account = app.accountRepository.all().firstOrNull { it.id() == accountId } - ?: throw IllegalStateException("Account not found") + ?: error("Account not found") val client = app.createApiClient(account) val endpointType = when (configuration.postType.postType) { @@ -393,12 +405,15 @@ private suspend fun persistPost( when (result) { is WpRequestResult.Success -> { Log.i("EditorActivity", "Post $postId persisted via REST API") + null } else -> { Log.e("EditorActivity", "Failed to persist post $postId: $result") + context.getString(R.string.save_failed_generic) } } } catch (e: Exception) { - Log.e("EditorActivity", "Save failed", e) + Log.e("EditorActivity", "Failed to persist post $postId", e) + context.getString(R.string.save_failed_with_reason, e.message ?: "unknown error") } } diff --git a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt index 6923aee11..a0b85f773 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt @@ -1,5 +1,6 @@ package com.example.gutenbergkit +import android.app.Application import android.content.Context import android.content.Intent import android.os.Bundle @@ -33,9 +34,9 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -59,7 +60,7 @@ import uniffi.wp_api.PostStatus * Lists posts from a WordPress site so the user can pick one to edit. * * Receives an [EditorConfiguration] (already prepared) and an account ID via Intent extras, - * fetches posts via the WordPress REST API using [WpApiClient], and on selection launches + * fetches posts via the WordPress REST API using `WpApiClient`, and on selection launches * [EditorActivity] with the post's title, content, and ID. */ class PostsListActivity : ComponentActivity() { @@ -102,10 +103,14 @@ class PostsListActivity : ComponentActivity() { return } - val viewModel = ViewModelProvider( - this, - PostsListViewModelFactory(application, accountId, postType) - )[PostsListViewModel::class.java] + // Inline factory — simpler than a dedicated `ViewModelProvider.Factory` + // class for a one-shot screen with immutable constructor args. + val factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + PostsListViewModel(application, accountId, postType) as T + } + val viewModel = ViewModelProvider(this, factory)[PostsListViewModel::class.java] setContent { AppTheme { @@ -150,10 +155,10 @@ data class PostsListUiState( ) class PostsListViewModel( - application: android.app.Application, + private val application: Application, private val accountId: ULong, private val postType: PostTypeDetails -) : AndroidViewModel(application) { +) : ViewModel() { private val _uiState = MutableStateFlow(PostsListUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -165,9 +170,9 @@ class PostsListViewModel( _uiState.update { it.copy(isLoading = true, error = null, posts = emptyList()) } try { - val app = getApplication() + val app = application as GutenbergKitApplication val account = app.accountRepository.all().firstOrNull { it.id() == accountId } - ?: throw IllegalStateException("Account not found") + ?: error("Account not found") val client = app.createApiClient(account) val endpointType = when (postType.postType) { @@ -176,32 +181,23 @@ class PostsListViewModel( else -> PostEndpointType.Custom(postType.postType) } - val all = mutableListOf() - var page = 1u - val perPage = 20u - while (true) { - val params = PostListParams( - page = page, - perPage = perPage, - status = listOf(PostStatus.Any) - ) - val result = client.request { builder -> - builder.posts().listWithEditContext(endpointType, params) + // Single page only — matches the iOS demo, which doesn't paginate either. + val params = PostListParams( + page = 1u, + perPage = 20u, + status = listOf(PostStatus.Any) + ) + val result = client.request { builder -> + builder.posts().listWithEditContext(endpointType, params) + } + when (result) { + is WpRequestResult.Success -> { + _uiState.update { it.copy(posts = result.response.data, isLoading = false) } } - when (result) { - is WpRequestResult.Success -> { - val data = result.response.data - all.addAll(data) - if (data.size < perPage.toInt()) break - page++ - } - else -> { - throw IllegalStateException("Failed to load posts: $result") - } + else -> { + error("Failed to load posts: $result") } } - - _uiState.update { it.copy(posts = all, isLoading = false) } } catch (e: Exception) { _uiState.update { it.copy(error = e.message ?: "Unknown error", isLoading = false) } } @@ -209,20 +205,6 @@ class PostsListViewModel( } } -class PostsListViewModelFactory( - private val application: android.app.Application, - private val accountId: ULong, - private val postType: PostTypeDetails -) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(PostsListViewModel::class.java)) { - return PostsListViewModel(application, accountId, postType) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun PostsListScreen( @@ -240,12 +222,12 @@ fun PostsListScreen( modifier = Modifier.fillMaxSize(), topBar = { TopAppBar( - title = { Text("Posts") }, + title = { Text(stringResource(R.string.posts)) }, navigationIcon = { IconButton(onClick = onClose) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" + contentDescription = stringResource(R.string.back) ) } } @@ -270,7 +252,7 @@ fun PostsListScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - "Error loading posts", + stringResource(R.string.error_loading_posts), style = MaterialTheme.typography.titleMedium ) Text( @@ -281,7 +263,7 @@ fun PostsListScreen( } uiState.posts.isEmpty() -> { Text( - "No posts found", + stringResource(R.string.no_posts_found), modifier = Modifier.align(Alignment.Center) ) } diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt index 7915d68e2..a66f9326a 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -49,11 +49,13 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModelProvider import com.example.gutenbergkit.ui.theme.AppTheme import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.EditorDependenciesSerializer import org.wordpress.gutenberg.model.PostTypeDetails @@ -164,7 +166,7 @@ class SitePreparationActivity : ComponentActivity() { private fun launchEditor( configuration: EditorConfiguration, - dependencies: org.wordpress.gutenberg.model.EditorDependencies? + dependencies: EditorDependencies? ) { val intent = Intent(this, EditorActivity::class.java).apply { putExtra(MainActivity.EXTRA_CONFIGURATION, configuration) @@ -182,7 +184,7 @@ class SitePreparationActivity : ComponentActivity() { accountId: ULong, postType: PostTypeDetails, configuration: EditorConfiguration, - dependencies: org.wordpress.gutenberg.model.EditorDependencies? + dependencies: EditorDependencies? ) { startActivity( PostsListActivity.createIntent(this, accountId, postType, configuration, dependencies) @@ -196,8 +198,8 @@ fun SitePreparationScreen( viewModel: SitePreparationViewModel, accountId: ULong?, onClose: () -> Unit, - onStartEditor: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?) -> Unit, - onBrowsePosts: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?, PostTypeDetails) -> Unit + onStartEditor: (EditorConfiguration, EditorDependencies?) -> Unit, + onBrowsePosts: (EditorConfiguration, EditorDependencies?, PostTypeDetails) -> Unit ) { val uiState by viewModel.uiState.collectAsState() @@ -214,7 +216,7 @@ fun SitePreparationScreen( IconButton(onClick = onClose) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" + contentDescription = stringResource(R.string.back) ) } }, @@ -472,7 +474,7 @@ private fun FeatureConfigurationCard( onClick = onBrowsePosts, modifier = Modifier.fillMaxWidth() ) { - Text("Browse") + Text(stringResource(R.string.browse)) } } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 30089f25b..40d2f403c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -39,4 +39,15 @@ SAVE Visual editor Code editor + Failed to save post + Failed to save post: %1$s + + + Browse + + + Posts + Back + No posts found + Error loading posts From 897b607e96ec0a161449f8f37acd285b968207a0 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 12:19:09 -0400 Subject: [PATCH 12/22] fix(demo-android): default post type picker to Post MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After fetching post types from the REST API, prefer `post` over the first item in the alphabetically-sorted list. Without this the picker defaulted to `page` (alphabetical first), which was surprising — `post` is the conventional default and matches what users expect from the prior hardcoded picker. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/example/gutenbergkit/SitePreparationViewModel.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt index 32aa1e232..30bfb961e 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -244,11 +244,12 @@ class SitePreparationViewModel( arrayOf() } - // Fetch the site's post types and pick the first one as the default - // selection. Falls back to `PostTypeDetails.post` if the call fails so - // the editor can still launch with a sensible default. + // Fetch the site's post types. Default the picker to `post` when it's + // available (the typical case); otherwise pick the first type in the + // list. Falls back to `PostTypeDetails.post` if the call fails so the + // editor can still launch with a sensible default. val postTypes = loadPostTypes(config) ?: listOf(PostTypeDetails.post) - val defaultPostType = postTypes.first() + val defaultPostType = postTypes.firstOrNull { it.postType == "post" } ?: postTypes.first() _uiState.update { it.copy( postTypes = postTypes, From 49e401cbf4df66e06272912c3e95e4f75d6c47c0 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 8 Apr 2026 10:12:08 -0400 Subject: [PATCH 13/22] refactor(demo-android): drop savePost lifecycle plumbing from persistPost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo's persistPost() wrapper used GutenbergView.savePost() to fire the editor store save lifecycle before reading content and PUTting via the REST API. That bridge method is introduced in a follow-up PR, so strip the savePostAwait() helper and its call site here — the demo's save flow now goes directly from reading title/content to the REST persistence step. The bridge call will be re-introduced in the savePost() lifecycle PR along with its resilient error handling. Also migrate the GBKitGlobal restBase tests from the previous PR to use PostTypeDetails, since the earlier String-slug arguments no longer compile against the refactored EditorConfiguration.postType field. --- .../gutenberg/model/GBKitGlobalTest.kt | 10 ++++--- .../example/gutenbergkit/EditorActivity.kt | 27 ------------------- 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt index 2ef407699..43d65de16 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt @@ -119,7 +119,7 @@ class GBKitGlobalTest { @Test fun `populates restBase and restNamespace for post type`() { - val configuration = makeConfiguration(postType = "post") + val configuration = makeConfiguration(postType = PostTypeDetails.post) val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) assertEquals("posts", global.post.restBase) assertEquals("wp/v2", global.post.restNamespace) @@ -127,15 +127,17 @@ class GBKitGlobalTest { @Test fun `populates restBase for page post type`() { - val configuration = makeConfiguration(postType = "page") + val configuration = makeConfiguration(postType = PostTypeDetails.page) val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) assertEquals("pages", global.post.restBase) assertEquals("wp/v2", global.post.restNamespace) } @Test - fun `pluralizes custom post type slugs for restBase`() { - val configuration = makeConfiguration(postType = "product") + fun `forwards custom PostTypeDetails restBase into the payload`() { + val configuration = makeConfiguration( + postType = PostTypeDetails(postType = "product", restBase = "products") + ) val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) assertEquals("products", global.post.restBase) } diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 49d59fa25..ab1731025 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -328,26 +328,8 @@ fun EditorScreen( } } -/** - * Suspends until the editor store's save lifecycle completes. - * - * Bridges the [GutenbergView.savePost] callback to a coroutine so the caller - * can sequence post-save work (like persisting content via the REST API). - */ -private suspend fun GutenbergView.savePostAwait(): Boolean = - suspendCancellableCoroutine { continuation -> - savePost { success, _ -> - if (continuation.isActive) continuation.resume(success) - } - } - /** * Reads the latest title/content from the editor and PUTs it to the WordPress REST API. - * - * Triggers [GutenbergView.savePost] first so plugin side-effects (e.g., VideoPress - * syncing metadata) settle before the content is read and persisted. A lifecycle - * failure must NOT block the user from saving their work — the warning is logged - * and persistence proceeds anyway. */ private suspend fun persistPost( context: Context, @@ -356,15 +338,6 @@ private suspend fun persistPost( accountId: ULong, postId: UInt ): String? { - // 1. Trigger the editor store save lifecycle so plugins fire side-effects. - val saveSucceeded = view.savePostAwait() - if (saveSucceeded) { - Log.i("EditorActivity", "editor.savePost() completed — editor store save lifecycle fired") - } else { - Log.w("EditorActivity", "editor.savePost() lifecycle failed; persisting anyway") - } - - // 2. Persist post content via REST API. return try { val titleAndContent = suspendCancellableCoroutine> { cont -> view.getTitleAndContent( From 19652cd54c8735dfe3e46918eef4449b28854e34 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 06:08:21 -0400 Subject: [PATCH 14/22] chore(demo-ios): point wordpress-rs to PR build branch Updates the wordpress-rs SPM dependency to track the pr-build/1270 branch, which contains the PostUpdateParams export needed by the demo app's Save button to persist posts via the REST API. See: https://github.com/Automattic/wordpress-rs/pull/1270 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Gutenberg.xcodeproj/project.pbxproj | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj index 6d619aadd..f2d53b48b 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj @@ -223,6 +223,25 @@ }; /* End PBXProject section */ +/* Begin PBXResourcesBuildPhase section */ + 0C4F59892BEFF4970028BD96 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0CE8E78F2C339B0600B9DC67 /* Preview Assets.xcassets in Resources */, + 0CE8E78B2C339B0600B9DC67 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AA0000012F00000000000005 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + /* Begin PBXShellScriptBuildPhase section */ AA0000012F00000000000200 /* Copy OAuth Credentials */ = { isa = PBXShellScriptBuildPhase; @@ -246,25 +265,6 @@ }; /* End PBXShellScriptBuildPhase section */ -/* Begin PBXResourcesBuildPhase section */ - 0C4F59892BEFF4970028BD96 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 0CE8E78F2C339B0600B9DC67 /* Preview Assets.xcassets in Resources */, - 0CE8E78B2C339B0600B9DC67 /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - AA0000012F00000000000005 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ 0C4F59872BEFF4970028BD96 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -561,8 +561,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Automattic/wordpress-rs"; requirement = { - kind = revision; - revision = "alpha-20260313"; + branch = "pr-build/1270"; + kind = branch; }; }; /* End XCRemoteSwiftPackageReference section */ From 25170148b71d1213e282a70c1891ef852cb38e66 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 6 Apr 2026 16:25:18 -0400 Subject: [PATCH 15/22] feat(demo-ios): add save button that persists posts via REST API Adds a "Save" button to the demo app's editor toolbar. Tapping it: 1. Calls EditorViewController.savePost() to trigger the editor store's save lifecycle, so plugins (e.g., VideoPress) fire their side-effect API calls. 2. Reads the latest title/content via getTitleAndContent() and persists the post via WordPressAPI's posts.updateCancellation() call. The API client is threaded from PostsListView through RunnableEditor and into EditorView. The Save button is disabled for new drafts (where postID is nil) since updating requires an existing post ID. Requires the PostUpdateParams export added in wordpress-rs feat/export-post-update-params branch. Co-Authored-By: Claude Opus 4.6 (1M context) --- ios/Demo-iOS/Sources/ConfigurationItem.swift | 16 +++++ ios/Demo-iOS/Sources/GutenbergApp.swift | 6 +- ios/Demo-iOS/Sources/Views/EditorView.swift | 67 ++++++++++++++++++- .../Sources/Views/PostsListView.swift | 3 +- 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/ios/Demo-iOS/Sources/ConfigurationItem.swift b/ios/Demo-iOS/Sources/ConfigurationItem.swift index 3cf171574..52f6320b9 100644 --- a/ios/Demo-iOS/Sources/ConfigurationItem.swift +++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift @@ -34,6 +34,22 @@ enum ConfigurationItem: Identifiable, Equatable, Hashable { struct RunnableEditor: Equatable, Hashable { let configuration: EditorConfiguration let dependencies: EditorDependencies? + let apiClient: WordPressAPI? + + init(configuration: EditorConfiguration, dependencies: EditorDependencies?, apiClient: WordPressAPI? = nil) { + self.configuration = configuration + self.dependencies = dependencies + self.apiClient = apiClient + } + + static func == (lhs: RunnableEditor, rhs: RunnableEditor) -> Bool { + lhs.configuration == rhs.configuration && lhs.dependencies == rhs.dependencies + } + + func hash(into hasher: inout Hasher) { + hasher.combine(configuration) + hasher.combine(dependencies) + } } /// Credentials loaded from the wp-env setup script output diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index 08650f077..3a741f03f 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -56,7 +56,11 @@ struct GutenbergApp: App { let editor = navigation.editor! NavigationStack { - EditorView(configuration: editor.configuration, dependencies: editor.dependencies) + EditorView( + configuration: editor.configuration, + dependencies: editor.dependencies, + apiClient: editor.apiClient + ) } } diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 6345c8dc5..1d03c5ac3 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -1,23 +1,27 @@ import SwiftUI import GutenbergKit +import WordPressAPI struct EditorView: View { private let configuration: EditorConfiguration private let dependencies: EditorDependencies? + private let apiClient: WordPressAPI? @State private var viewModel = EditorViewModel() @Environment(\.dismiss) var dismiss - init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil) { + init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, apiClient: WordPressAPI? = nil) { self.configuration = configuration self.dependencies = dependencies + self.apiClient = apiClient } var body: some View { _EditorView( configuration: configuration, dependencies: dependencies, + apiClient: apiClient, viewModel: viewModel ) .toolbar { toolbar } @@ -58,6 +62,17 @@ struct EditorView: View { moreMenu .disabled(viewModel.isModalDialogOpen) } + + ToolbarItem(placement: .topBarTrailing) { + Button { + viewModel.save() + } label: { + Text("Save") + .fontWeight(.semibold) + } + .disabled(!viewModel.canSave) + .accessibilityLabel("Save") + } } private var moreMenu: some View { @@ -99,15 +114,18 @@ struct EditorView: View { private struct _EditorView: UIViewControllerRepresentable { private let configuration: EditorConfiguration private let dependencies: EditorDependencies? + private let apiClient: WordPressAPI? private let viewModel: EditorViewModel init( configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, + apiClient: WordPressAPI? = nil, viewModel: EditorViewModel ) { self.configuration = configuration self.dependencies = dependencies + self.apiClient = apiClient self.viewModel = viewModel } @@ -127,6 +145,33 @@ private struct _EditorView: UIViewControllerRepresentable { } } + viewModel.hasPostID = configuration.postID != nil + + viewModel.saveHandler = { [weak viewController, configuration, apiClient] in + guard let viewController else { return } + do { + // 1. Trigger the editor store save lifecycle so plugins fire side-effects + try await viewController.savePost() + print("savePost() completed — editor store save lifecycle fired") + + // 2. Persist post content via REST API + if let apiClient, let postID = configuration.postID { + let titleAndContent = try await viewController.getTitleAndContent() + let params = PostUpdateParams(title: .some(titleAndContent.title), content: .some(titleAndContent.content), meta: nil) + let endpointType: PostEndpointType = configuration.postType.postType == "page" ? .pages : .posts + _ = try await apiClient.posts.updateCancellation( + postEndpointType: endpointType, + postId: Int64(postID), + params: params, + context: nil + ) + print("Post \(postID) persisted via REST API") + } + } catch { + print("Save failed: \(error)") + } + } + return viewController } @@ -145,7 +190,7 @@ private struct _EditorView: UIViewControllerRepresentable { // MARK: - EditorViewControllerDelegate func editorDidLoad(_ viewContoller: EditorViewController) { - // No-op for demo + viewModel.isEditorReady = true } func editor(_ viewContoller: EditorViewController, didDisplayInitialContent content: String) { @@ -232,6 +277,14 @@ private final class EditorViewModel { var hasUndo = false var hasRedo = false var isCodeEditorEnabled = false + var isSaving = false + var isEditorReady = false + + var hasPostID = false + + var canSave: Bool { + isEditorReady && !isSaving && hasPostID + } enum Action { case undo @@ -239,6 +292,16 @@ private final class EditorViewModel { } var perform: (_ action: Action) -> Void = { _ in assertionFailure() } + var saveHandler: () async -> Void = {} + + func save() { + guard canSave else { return } + isSaving = true + Task { + await saveHandler() + isSaving = false + } + } } #Preview { diff --git a/ios/Demo-iOS/Sources/Views/PostsListView.swift b/ios/Demo-iOS/Sources/Views/PostsListView.swift index db0dfb1d1..31ccac38c 100644 --- a/ios/Demo-iOS/Sources/Views/PostsListView.swift +++ b/ios/Demo-iOS/Sources/Views/PostsListView.swift @@ -77,7 +77,8 @@ struct PostsListView: View { let editor = RunnableEditor( configuration: configuration, - dependencies: viewModel.editorDependencies + dependencies: viewModel.editorDependencies, + apiClient: viewModel.client ) navigation.present(editor) From 5ae4c07a48cbf6bce34d5cdc114fb2eb0e1497c1 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 10:19:23 -0400 Subject: [PATCH 16/22] refactor(demo-ios): remove permanently disabled items from more menu Removes the placeholder Preview, Revisions, Post Settings, Help, and the static block/word/character count footer from the editor's more menu. Only the working "Code Editor / Visual Editor" toggle remains. Co-Authored-By: Claude Opus 4.6 (1M context) --- ios/Demo-iOS/Sources/Views/EditorView.swift | 35 +++++---------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 1d03c5ac3..9aca2d477 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -77,33 +77,14 @@ struct EditorView: View { private var moreMenu: some View { Menu { - Section { - Button(action: { - viewModel.isCodeEditorEnabled.toggle() - }, label: { - Label( - viewModel.isCodeEditorEnabled ? "Visual Editor" : "Code Editor", - systemImage: viewModel.isCodeEditorEnabled ? "doc.richtext" : "curlybraces" - ) - }) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Preview", systemImage: "safari") - }).disabled(true) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Revisions (42)", systemImage: "clock.arrow.circlepath") - }).disabled(true) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Post Settings", systemImage: "gearshape") - }).disabled(true) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Help", systemImage: "questionmark.circle") - }).disabled(true) - } - Section { - Text("Blocks: 4, Words: 8, Characters: 15") - } header: { - - } + Button(action: { + viewModel.isCodeEditorEnabled.toggle() + }, label: { + Label( + viewModel.isCodeEditorEnabled ? "Visual Editor" : "Code Editor", + systemImage: viewModel.isCodeEditorEnabled ? "doc.richtext" : "curlybraces" + ) + }) } label: { Image(systemName: "ellipsis") } From 7611f60f7cbddd802541289549692d32f29f626d Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 12:08:20 -0400 Subject: [PATCH 17/22] refactor(demo-ios): extract persistPost helper, document RunnableEditor - Move the long save closure body out of `viewModel.saveHandler =` and into a `private func persistPost(...)` on `_EditorView`. The saveHandler closure is now a one-liner that delegates, mirroring how `viewModel.perform` is wired for undo/redo while keeping the actual save logic readable as a regular method. - Add an inline comment to `RunnableEditor` explaining why `apiClient` is excluded from `==` and `hash(into:)`: `WordPressAPI` isn't `Hashable`/`Equatable` (it owns native Rust state), and two editors with the same configuration but different client instances should be treated as equal for navigation/identity purposes. Co-Authored-By: Claude Opus 4.6 (1M context) --- ios/Demo-iOS/Sources/ConfigurationItem.swift | 4 ++ ios/Demo-iOS/Sources/Views/EditorView.swift | 45 ++++++++++---------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/ios/Demo-iOS/Sources/ConfigurationItem.swift b/ios/Demo-iOS/Sources/ConfigurationItem.swift index 52f6320b9..88eb12489 100644 --- a/ios/Demo-iOS/Sources/ConfigurationItem.swift +++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift @@ -42,6 +42,10 @@ struct RunnableEditor: Equatable, Hashable { self.apiClient = apiClient } + // `apiClient` is intentionally excluded from `==` and `hash(into:)`: + // `WordPressAPI` is not `Hashable`/`Equatable` (it owns native Rust state), + // and two editors with the same configuration but different client + // instances should be treated as equal for navigation/identity purposes. static func == (lhs: RunnableEditor, rhs: RunnableEditor) -> Bool { lhs.configuration == rhs.configuration && lhs.dependencies == rhs.dependencies } diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 9aca2d477..8a150437e 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -128,29 +128,9 @@ private struct _EditorView: UIViewControllerRepresentable { viewModel.hasPostID = configuration.postID != nil - viewModel.saveHandler = { [weak viewController, configuration, apiClient] in - guard let viewController else { return } - do { - // 1. Trigger the editor store save lifecycle so plugins fire side-effects - try await viewController.savePost() - print("savePost() completed — editor store save lifecycle fired") - - // 2. Persist post content via REST API - if let apiClient, let postID = configuration.postID { - let titleAndContent = try await viewController.getTitleAndContent() - let params = PostUpdateParams(title: .some(titleAndContent.title), content: .some(titleAndContent.content), meta: nil) - let endpointType: PostEndpointType = configuration.postType.postType == "page" ? .pages : .posts - _ = try await apiClient.posts.updateCancellation( - postEndpointType: endpointType, - postId: Int64(postID), - params: params, - context: nil - ) - print("Post \(postID) persisted via REST API") - } - } catch { - print("Save failed: \(error)") - } + viewModel.saveHandler = { [weak viewController, weak viewModel] in + guard let viewController, let viewModel else { return } + await persistPost(viewController: viewController, viewModel: viewModel) } return viewController @@ -160,6 +140,25 @@ private struct _EditorView: UIViewControllerRepresentable { viewController.isCodeEditorEnabled = viewModel.isCodeEditorEnabled } + /// Persists the post via the REST API. + private func persistPost(viewController: EditorViewController, viewModel: EditorViewModel) async { + guard let apiClient, let postID = configuration.postID else { return } + do { + let titleAndContent = try await viewController.getTitleAndContent() + let params = PostUpdateParams(title: .some(titleAndContent.title), content: .some(titleAndContent.content), meta: nil) + let endpointType: PostEndpointType = configuration.postType.postType == "page" ? .pages : .posts + _ = try await apiClient.posts.updateCancellation( + postEndpointType: endpointType, + postId: Int64(postID), + params: params, + context: nil + ) + print("Post \(postID) persisted via REST API") + } catch { + print("Failed to persist post \(postID): \(error)") + } + } + @MainActor class Coordinator: NSObject, EditorViewControllerDelegate { let viewModel: EditorViewModel From 28afce4452ce751b0065ebbc75ea743ea311fcb1 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 8 Apr 2026 10:41:33 -0400 Subject: [PATCH 18/22] fix(demo-android): use WP.com API URL resolver for namespaced sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createApiClient() previously constructed every WpApiClient via wpOrgSiteApiRootUrl, which resolves paths assuming the self-hosted /wp/v2/... layout. For WP.com accounts that produced double-prefixed URLs like: https://public-api.wordpress.com/wp/v2/sites/229672404/wp/v2/posts — and the WP.com REST API responded with rest_no_route, breaking both the post type picker (only "Post" appeared) and the posts list (404 on browse). Same root cause for both screens: they both go through createApiClient(). Switch to constructing WpApiClient with an explicit ApiUrlResolver: - WP.com accounts use WpComDotOrgApiUrlResolver(siteId, .Production), matching how the iOS demo wires its WordPressAPI client. The site id is extracted from the existing siteApiRoot via the same regex used by SitePreparationViewModel. - Self-hosted accounts continue to use WpOrgSiteApiUrlResolver against the site api root. After this change the post type picker fetches from the correct /wp/v2/sites/{id}/types path and the posts list fetches from /wp/v2/sites/{id}/posts. --- .../gutenbergkit/GutenbergKitApplication.kt | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt b/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt index f05d0397d..51df489e2 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt @@ -3,12 +3,16 @@ package com.example.gutenbergkit import android.app.Application import android.net.ConnectivityManager import android.net.NetworkCapabilities -import java.net.URI import rs.wordpress.api.android.KeystorePasswordTransformer import rs.wordpress.api.kotlin.NetworkAvailabilityProvider import rs.wordpress.api.kotlin.WpApiClient +import rs.wordpress.api.kotlin.WpRequestExecutor +import uniffi.wp_api.ParsedUrl import uniffi.wp_api.WpAuthentication import uniffi.wp_api.WpAuthenticationProvider +import uniffi.wp_api.WpComBaseUrl +import uniffi.wp_api.WpComDotOrgApiUrlResolver +import uniffi.wp_api.WpOrgSiteApiUrlResolver import uniffi.wp_api.wpAuthenticationFromUsernameAndPassword import uniffi.wp_mobile.Account import uniffi.wp_mobile.AccountRepository @@ -36,6 +40,11 @@ class GutenbergKitApplication : Application() { /** * Constructs a [WpApiClient] for the given [account]. Used by the demo app's posts * list and Save button to fetch/persist posts via the WordPress REST API. + * + * For WP.com accounts, uses [WpComDotOrgApiUrlResolver] so requests are routed + * through `/wp/v2/sites/{blogId}/...` instead of the self-hosted layout. Otherwise, + * the resolver double-prefixes paths and the WP.com REST API returns + * `rest_no_route`. */ fun createApiClient(account: Account): WpApiClient { val auth = when (account) { @@ -45,15 +54,37 @@ class GutenbergKitApplication : Application() { ) is Account.WpCom -> WpAuthentication.Bearer(token = account.token) } - val apiRootUrl = when (account) { - is Account.SelfHostedSite -> account.siteApiRoot - is Account.WpCom -> account.siteApiRoot - } - return WpApiClient( - wpOrgSiteApiRootUrl = URI(apiRootUrl).toURL(), - authProvider = WpAuthenticationProvider.staticWithAuth(auth), + val authProvider = WpAuthenticationProvider.staticWithAuth(auth) + val requestExecutor = WpRequestExecutor( interceptors = emptyList(), networkAvailabilityProvider = networkAvailabilityProvider ) + + val apiUrlResolver = when (account) { + is Account.WpCom -> { + val siteId = extractWpComSiteId(account.siteApiRoot) + ?: error("Could not extract WP.com site id from ${account.siteApiRoot}") + WpComDotOrgApiUrlResolver(siteId = siteId, baseUrl = WpComBaseUrl.Production) + } + is Account.SelfHostedSite -> WpOrgSiteApiUrlResolver( + apiRootUrl = ParsedUrl.parse(account.siteApiRoot) + ) + } + + return WpApiClient( + apiUrlResolver = apiUrlResolver, + authProvider = authProvider, + requestExecutor = requestExecutor + ) + } + + /** + * Extracts the WP.com blog id from a namespace-specific API root URL such as + * `https://public-api.wordpress.com/wp/v2/sites/229672404`. Returns null if the + * URL is not a WP.com API root. + */ + private fun extractWpComSiteId(siteApiRoot: String): String? { + val regex = Regex("""public-api\.wordpress\.com/.+/sites/(\d+)""") + return regex.find(siteApiRoot)?.groupValues?.get(1) } } From bc0e3b7337854c394750b8d5d47cbabbd318c186 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 8 Apr 2026 10:53:18 -0400 Subject: [PATCH 19/22] chore(demo-ios): update Package.resolved for wordpress-rs PR build branch Locks the SPM resolution to the wordpress-rs pr-build/1270 commit so fresh Xcode checkouts don't see a dirty Package.resolved on first open. Companion to the SPM dependency bump introduced earlier in this branch. --- Package.resolved | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index 62c421983..acfc70d96 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b5958ced5a4c7d544f45cfa6cdc8cd0441f5e176874baac30922b53e6cc5aefc", + "originHash" : "e4f07fb846fe4e484ddbb0d1c5783d0a62ffee053e850e8a2abe0b8e0323bbd4", "pins" : [ { "identity" : "svgview", @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Automattic/wordpress-rs", "state" : { - "branch" : "alpha-20260313", - "revision" : "cde2fda82257f4ac7b81543d5b831bb267d4e52c" + "branch" : "pr-build/1270", + "revision" : "22d23be14a44981c6aa0004b7f31ce2ec939df18" } } ], From 59025fcbec79b55e10000d41c73e4aa85f97ddb8 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 8 Apr 2026 10:53:27 -0400 Subject: [PATCH 20/22] fix(demo): show raw post titles in posts list, not HTML-encoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list view rendered title?.rendered, which is the WordPress-encoded form intended for HTML insertion — so titles containing non-breaking spaces showed up as e.g. \`A new post 2\`. The editor handoff was already correctly using title?.raw; this aligns the display path on both Android and iOS to do the same, falling back to rendered only if raw is missing. --- .../main/java/com/example/gutenbergkit/PostsListActivity.kt | 6 +++++- ios/Demo-iOS/Sources/Views/PostsListView.swift | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt index a0b85f773..933c07c04 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt @@ -292,7 +292,11 @@ private fun PostRow(post: AnyPostWithEditContext, onClick: () -> Unit) { .padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - val title = post.title?.rendered?.ifBlank { "(no title)" } ?: "(no title)" + // Prefer the raw title (database value) over rendered, which is HTML-encoded + // for insertion into a page (e.g. spaces become ` `). + val title = post.title?.raw?.ifBlank { null } + ?: post.title?.rendered?.ifBlank { null } + ?: "(no title)" Text( text = title, style = MaterialTheme.typography.titleMedium, diff --git a/ios/Demo-iOS/Sources/Views/PostsListView.swift b/ios/Demo-iOS/Sources/Views/PostsListView.swift index 31ccac38c..f0f3331aa 100644 --- a/ios/Demo-iOS/Sources/Views/PostsListView.swift +++ b/ios/Demo-iOS/Sources/Views/PostsListView.swift @@ -47,7 +47,9 @@ struct PostsListView: View { openPost(post) } label: { VStack(alignment: .leading, spacing: 4) { - Text(post.title?.rendered ?? "") + // Prefer the raw title (database value) over rendered, which is + // HTML-encoded for insertion into a page (e.g. spaces become ` `). + Text(post.title?.raw ?? post.title?.rendered ?? "") .font(.headline) if let excerpt = post.excerpt?.rendered, !excerpt.isEmpty { Text(excerpt.strippingHTML()) From d847f9314d8a674837abc580a905b79bc0a58503 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 8 Apr 2026 12:22:47 -0400 Subject: [PATCH 21/22] fix(demo-ios): route custom post types to .custom endpoint on save The save flow previously fell through to `.posts` for any post type that wasn't `page`, so custom post types would hit the wrong REST route. Mirror the Android `EditorActivity.persistPost` mapping and the existing `PostsListView` load flow by using `.custom(restBase)` for non-standard types. Co-Authored-By: Claude Opus 4.6 (1M context) --- ios/Demo-iOS/Sources/Views/EditorView.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 8a150437e..6400c670b 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -146,7 +146,15 @@ private struct _EditorView: UIViewControllerRepresentable { do { let titleAndContent = try await viewController.getTitleAndContent() let params = PostUpdateParams(title: .some(titleAndContent.title), content: .some(titleAndContent.content), meta: nil) - let endpointType: PostEndpointType = configuration.postType.postType == "page" ? .pages : .posts + let endpointType: PostEndpointType + switch configuration.postType.postType { + case "post": + endpointType = .posts + case "page": + endpointType = .pages + default: + endpointType = .custom(configuration.postType.restBase) + } _ = try await apiClient.posts.updateCancellation( postEndpointType: endpointType, postId: Int64(postID), From ae9b4f9209c2c4abd2f02f8ea9da9c2801e309cf Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 8 Apr 2026 16:20:39 -0400 Subject: [PATCH 22/22] refactor(demo-ios): use WordPressAPIInternal for PostUpdateParams Reverts the wordpress-rs SPM pin back to the alpha-20260313 tag and reaches PostUpdateParams through the WordPressAPIInternal module instead. The public typealias that exports PostUpdateParams from WordPressAPI only exists on wordpress-rs trunk (via #1270), not on any published tag, so the previous approach forced the demo onto a PR build branch. Importing the internal module mirrors the workaround WordPress-iOS uses in Modules/Sources/WordPressCore/ApiCache.swift and lets us stay on the tagged release until a tag including #1270 is cut. Co-Authored-By: Claude Opus 4.6 (1M context) --- Package.resolved | 4 ++-- ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj | 4 ++-- ios/Demo-iOS/Sources/Views/EditorView.swift | 5 +++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Package.resolved b/Package.resolved index acfc70d96..72ad0425b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Automattic/wordpress-rs", "state" : { - "branch" : "pr-build/1270", - "revision" : "22d23be14a44981c6aa0004b7f31ce2ec939df18" + "branch" : "alpha-20260313", + "revision" : "cde2fda82257f4ac7b81543d5b831bb267d4e52c" } } ], diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj index f2d53b48b..514a3d4ef 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj @@ -561,8 +561,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Automattic/wordpress-rs"; requirement = { - branch = "pr-build/1270"; - kind = branch; + kind = revision; + revision = "alpha-20260313"; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 6400c670b..23dcf7362 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -1,6 +1,11 @@ import SwiftUI import GutenbergKit import WordPressAPI +// `PostUpdateParams` is not yet re-exported from `WordPressAPI` in the pinned +// wordpress-rs release. It is reachable via the internal module, which is the +// same workaround WordPress-iOS uses. Remove this import once a tagged release +// including Automattic/wordpress-rs#1270 is adopted. +import WordPressAPIInternal struct EditorView: View { private let configuration: EditorConfiguration