Skip to content

Commit ff597e0

Browse files
nbradburyclaude
andauthored
Pages list: wordpress-rs-backed Compose screen (phases 0-1) (#22936)
* Pages list: Phase 0 prep for wordpress-rs migration - Add RS_PAGES_LIST experimental feature flag, hidden from release builds via the debug-only branch of shouldShowFeature alongside EXPERIMENTAL_POST_TYPES. Follows the naming convention of the recently-removed RS_POST_LIST flag. - Stub PageRsToFluxCMapper that delegates to PostRsToFluxCMapper and flips isPage = true. wordpress-rs treats pages as a variant of posts via PostEndpointType.Pages, so AnyPostWithEditContext is reused. - Stub PageRsFluxCBridge mirroring PostRsFluxCBridge but targeting PostEndpointType.Pages, with the fast path gated on cached.isPage to avoid colliding with a same-id post row. - Unit tests for both stubs covering fast-path hits and the isPage-mismatch fall-through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Drop redundant comment in PageRsFluxCBridge Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: Phase 1 read path & tabs Adds a Compose-based PagesRsListActivity backed by wordpress-rs: PUBLISHED / DRAFTS / SCHEDULED / TRASHED tabs subscribe to the same observable post-metadata collection used by the modern post list, with PostEndpointType.Pages selecting the pages surface. Includes pull-to-refresh, pagination, error/empty states, and click-through to the existing editor via the Phase 0 PageRsFluxCBridge. Routing through ActivityLauncher.viewCurrentBlogPages is gated by the RS_PAGES_LIST flag plus a WPCom-or-app-password capability check; a new Hilt EntryPoint lets the static launcher resolve ExperimentalFeatures without threading dependencies through every caller. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: tighten visibility and trim duplication Make pages-rs classes internal, drop unused UI model fields, reuse PlaceholderItem from postsrs, and skip redundant tab state emissions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: inline mapper and drop redundant check Fold PageRsToFluxCMapper's one-line isPage flip into PageRsFluxCBridge, removing the wrapper class and its dedicated test. Drop the redundant pages.isEmpty() clause in the error branch of PageRsTabListScreen — error is only ever set when the page list is already empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: satisfy detekt and checkstyle Collapse two early-return guards in PagesRsListViewModel.openPage to stay under detekt's ReturnCount limit, and move the dagger import in ActivityLauncher to the bottom group where checkstyle expects it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: scope page lookup to tab and simplify tab change tracking Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: require application password for wp.com too Match the new post list gate so wp.com sites also need an application password before opening the new pages list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: keep isAuthError fresh and clear on success Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: recover from failed init and dedupe error snackbar Make refreshTab fall back to initTab when no collection exists so the Retry action on a failed init actually retries; reset the tab to a loading state on init so the prior error UI clears. Drop the duplicate snackbar from the list-info observer — the refreshTab / loadMorePages catch blocks already emit one with proper auth classification. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: skip wasted fetch and guard ViewModel against null site PageRsFluxCBridge: short-circuit when the cached row has unsaved local edits. FluxC's insertOrUpdatePost(post, false) refuses to overwrite a locally-changed row, so re-fetching from the server only to discard the response is wasted work. Soften the KDoc to match. PagesRsListViewModel: drop the throwing `site` getter and guard each public entry point with `_site ?: return`. Previously a tab swipe in the brief window between VM init and the Activity collecting the Finish event could throw from requireNotNull; the other entry points avoided crashing only because their generic catches swallowed the throw. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d973e15 commit ff597e0

18 files changed

Lines changed: 1422 additions & 5 deletions

WordPress/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,10 @@
371371
android:theme="@style/WordPress.NoActionBar"
372372
android:launchMode="singleTop"
373373
android:label="@string/my_site_btn_site_pages" />
374+
<activity
375+
android:name=".ui.pagesrs.PagesRsListActivity"
376+
android:theme="@style/WordPress.NoActionBar"
377+
android:label="@string/my_site_btn_site_pages" />
374378
<activity
375379
android:name=".ui.pages.PageParentActivity"
376380
android:theme="@style/WordPress.NoActionBar"

WordPress/src/main/java/org/wordpress/android/ui/ActivityLauncher.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
import org.wordpress.android.ui.posts.PostUtils.EntryPoint;
9393
import org.wordpress.android.ui.posts.PostsListActivity;
9494
import org.wordpress.android.ui.posts.RemotePreviewLogicHelper.RemotePreviewType;
95+
import org.wordpress.android.ui.pagesrs.PagesRsListActivity;
9596
import org.wordpress.android.ui.postsrs.PostRsListActivity;
9697
import org.wordpress.android.posttypes.CptPostTypesActivity;
9798
import org.wordpress.android.posttypes.bridge.SiteReference;
@@ -145,6 +146,8 @@
145146
import java.util.List;
146147
import java.util.Map;
147148

149+
import dagger.hilt.android.EntryPointAccessors;
150+
148151
import static org.wordpress.android.analytics.AnalyticsTracker.ACTIVITY_LOG_ACTIVITY_ID_KEY;
149152
import static org.wordpress.android.analytics.AnalyticsTracker.Stat.POST_LIST_ACCESS_ERROR;
150153
import static org.wordpress.android.analytics.AnalyticsTracker.Stat.READER_ARTICLE_DETAIL_REBLOGGED;
@@ -684,12 +687,28 @@ public static void viewCurrentBlogMedia(Context context, SiteModel site) {
684687
}
685688

686689
public static void viewCurrentBlogPages(@NonNull Context context, @NonNull SiteModel site) {
690+
if (shouldUseNewPagesList(context, site)) {
691+
context.startActivity(PagesRsListActivity.Companion.createIntent(context));
692+
AnalyticsUtils.trackWithSiteDetails(AnalyticsTracker.Stat.OPENED_PAGES, site);
693+
return;
694+
}
687695
Intent intent = new Intent(context, PagesActivity.class);
688696
intent.putExtra(WordPress.SITE, site);
689697
context.startActivity(intent);
690698
AnalyticsUtils.trackWithSiteDetails(AnalyticsTracker.Stat.OPENED_PAGES, site);
691699
}
692700

701+
private static boolean shouldUseNewPagesList(@NonNull Context context, @NonNull SiteModel site) {
702+
if (!site.hasApplicationPassword()) {
703+
return false;
704+
}
705+
ActivityLauncherEntryPoint entryPoint = EntryPointAccessors.fromApplication(
706+
context.getApplicationContext(),
707+
ActivityLauncherEntryPoint.class
708+
);
709+
return entryPoint.experimentalFeatures().isEnabled(Feature.RS_PAGES_LIST);
710+
}
711+
693712
public static void viewPostTypes(@NonNull Context context, @NonNull SiteModel site) {
694713
SiteReference siteRef = SiteReference.Companion.create(
695714
site.getSiteId(),
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.wordpress.android.ui
2+
3+
import dagger.hilt.EntryPoint
4+
import dagger.hilt.InstallIn
5+
import dagger.hilt.components.SingletonComponent
6+
import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures
7+
8+
/**
9+
* Hilt entry point for static [ActivityLauncher] helpers that need access to singleton
10+
* dependencies. Used to gate activity routing decisions (e.g. the modern pages list flag)
11+
* without threading dependencies through every caller.
12+
*/
13+
@EntryPoint
14+
@InstallIn(SingletonComponent::class)
15+
interface ActivityLauncherEntryPoint {
16+
fun experimentalFeatures(): ExperimentalFeatures
17+
}

WordPress/src/main/java/org/wordpress/android/ui/main/BaseAppCompatActivity.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import org.wordpress.android.ui.media.MediaSettingsActivity
2020
import org.wordpress.android.ui.mysite.menu.MenuActivity
2121
import org.wordpress.android.ui.mysite.personalization.PersonalizationActivity
2222
import org.wordpress.android.ui.navmenus.NavMenusActivity
23+
import org.wordpress.android.ui.pagesrs.PagesRsListActivity
2324
import org.wordpress.android.ui.posts.GutenbergKitActivity
2425
import org.wordpress.android.ui.postsrs.PostRsListActivity
2526
import org.wordpress.android.ui.postsrs.PostRsSettingsActivity
@@ -93,6 +94,7 @@ private val excludedActivities = listOf(
9394
NavMenusActivity::class.java.name,
9495
NewDomainSearchActivity::class.java.name,
9596
PersonalizationActivity::class.java.name,
97+
PagesRsListActivity::class.java.name,
9698
PostRsListActivity::class.java.name,
9799
PostRsSettingsActivity::class.java.name,
98100
TermSelectionActivity::class.java.name,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package org.wordpress.android.ui.pagesrs
2+
3+
import org.wordpress.android.fluxc.model.PostModel
4+
import org.wordpress.android.fluxc.model.SiteModel
5+
import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider
6+
import org.wordpress.android.fluxc.persistence.PostSqlUtils
7+
import org.wordpress.android.fluxc.store.PostStore
8+
import org.wordpress.android.ui.postsrs.PostRsToFluxCMapper
9+
import rs.wordpress.api.kotlin.WpRequestResult
10+
import uniffi.wp_api.PostEndpointType
11+
import uniffi.wp_api.PostRetrieveParams
12+
import javax.inject.Inject
13+
14+
/**
15+
* Bridges a wordpress-rs page into FluxC's local SQLite database so the
16+
* editor (which reads from FluxC) can open it. Mirrors
17+
* [org.wordpress.android.ui.postsrs.PostRsFluxCBridge] but targets
18+
* [PostEndpointType.Pages] and ensures the resulting [PostModel] has
19+
* `isPage = true`.
20+
*
21+
* **Fast path**: if the page is already in FluxC's DB (with `isPage = true`)
22+
* and not known to be stale, returns it immediately without a network call.
23+
*
24+
* **Slow path**: fetches the full page via wordpress-rs, maps it to a
25+
* [PostModel], inserts it into FluxC's DB, and re-reads it to obtain the
26+
* auto-assigned local ID.
27+
*/
28+
internal class PageRsFluxCBridge @Inject constructor(
29+
private val wpApiClientProvider: WpApiClientProvider,
30+
private val postStore: PostStore,
31+
private val postSqlUtils: PostSqlUtils,
32+
private val postMapper: PostRsToFluxCMapper,
33+
) {
34+
/**
35+
* Returns a [PostModel] for [remotePageId] that exists in FluxC's local
36+
* database with `isPage = true`. If [lastModified] is provided and differs
37+
* from the cached row's `remoteLastModified`, the cache is considered stale
38+
* and the page is re-fetched from the server — unless the cached row has
39+
* unsaved local edits, in which case it is returned as-is to preserve them
40+
* (FluxC will not overwrite a locally-changed row, so a network fetch in
41+
* that case would be wasted).
42+
*
43+
* @throws IllegalStateException if the page cannot be fetched or inserted.
44+
*/
45+
suspend fun fetchAndBridge(
46+
remotePageId: Long,
47+
site: SiteModel,
48+
lastModified: String? = null
49+
): PostModel {
50+
postStore.getPostByRemotePostId(remotePageId, site)?.let { cached ->
51+
val fresh = lastModified == null ||
52+
lastModified == cached.remoteLastModified
53+
if (cached.isPage && (fresh || cached.isLocallyChanged)) {
54+
return cached
55+
}
56+
}
57+
58+
val client = wpApiClientProvider.getWpApiClient(site)
59+
val response = client.request {
60+
it.posts().retrieveWithEditContext(
61+
PostEndpointType.Pages,
62+
remotePageId,
63+
PostRetrieveParams()
64+
)
65+
}
66+
val rsPage = when (response) {
67+
is WpRequestResult.Success -> response.response.data
68+
else -> {
69+
val msg = (response as? WpRequestResult.WpError<*>)
70+
?.errorMessage ?: "Failed to fetch page"
71+
throw IllegalStateException(msg)
72+
}
73+
}
74+
75+
val pageModel = postMapper.map(rsPage, site).apply { setIsPage(true) }
76+
postSqlUtils.insertOrUpdatePost(pageModel, false)
77+
78+
return postStore.getPostByRemotePostId(remotePageId, site)
79+
?: error("Page inserted but not found in FluxC")
80+
}
81+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.wordpress.android.ui.pagesrs
2+
3+
import org.wordpress.android.fluxc.model.PostModel
4+
import org.wordpress.android.fluxc.model.SiteModel
5+
6+
internal sealed interface PageRsListEvent {
7+
data class EditPage(
8+
val site: SiteModel,
9+
val page: PostModel
10+
) : PageRsListEvent
11+
12+
data class ShowToast(
13+
val messageResId: Int
14+
) : PageRsListEvent
15+
16+
data object Finish : PageRsListEvent
17+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package org.wordpress.android.ui.pagesrs
2+
3+
import org.wordpress.android.R
4+
import uniffi.wp_api.PostStatus
5+
import uniffi.wp_api.WpApiParamOrder
6+
7+
internal enum class PageRsListTab(
8+
val labelResId: Int,
9+
val emptyMessageResId: Int,
10+
val statuses: List<PostStatus>,
11+
val order: WpApiParamOrder
12+
) {
13+
PUBLISHED(
14+
labelResId = R.string.pages_published,
15+
emptyMessageResId = R.string.pages_empty_published,
16+
statuses = listOf(PostStatus.Publish, PostStatus.Private),
17+
order = WpApiParamOrder.DESC
18+
),
19+
DRAFTS(
20+
labelResId = R.string.pages_drafts,
21+
emptyMessageResId = R.string.pages_empty_drafts,
22+
statuses = listOf(PostStatus.Draft, PostStatus.Pending),
23+
order = WpApiParamOrder.DESC
24+
),
25+
SCHEDULED(
26+
labelResId = R.string.pages_scheduled,
27+
emptyMessageResId = R.string.pages_empty_scheduled,
28+
statuses = listOf(PostStatus.Future),
29+
order = WpApiParamOrder.ASC
30+
),
31+
TRASHED(
32+
labelResId = R.string.pages_trashed,
33+
emptyMessageResId = R.string.pages_empty_trashed,
34+
statuses = listOf(PostStatus.Trash),
35+
order = WpApiParamOrder.DESC
36+
);
37+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package org.wordpress.android.ui.pagesrs
2+
3+
import org.wordpress.android.R
4+
import org.wordpress.android.ui.postsrs.PostRsDateFormatter
5+
import org.wordpress.android.util.DateTimeUtils
6+
import org.wordpress.android.util.HtmlUtils
7+
import uniffi.wp_api.AnyPostWithEditContext
8+
import uniffi.wp_api.PostStatus
9+
import uniffi.wp_mobile.FullEntityAnyPostWithEditContext
10+
import uniffi.wp_mobile.PostItemState
11+
12+
internal data class PageTabUiState(
13+
val pages: List<PageRsUiModel> = emptyList(),
14+
val isLoading: Boolean = false,
15+
val isRefreshing: Boolean = false,
16+
val isLoadingMore: Boolean = false,
17+
val canLoadMore: Boolean = false,
18+
val error: String? = null,
19+
val isAuthError: Boolean = false
20+
)
21+
22+
internal enum class PageRsDisplayState {
23+
NORMAL,
24+
FETCHING_WITH_DATA,
25+
FAILED_WITH_DATA,
26+
PLACEHOLDER,
27+
ERROR
28+
}
29+
30+
internal data class PageRsUiModel(
31+
val remotePageId: Long,
32+
val title: String,
33+
val excerpt: String,
34+
val date: String,
35+
val lastModified: String = "",
36+
val badges: List<Int> = emptyList(),
37+
val displayState: PageRsDisplayState = PageRsDisplayState.NORMAL
38+
)
39+
40+
internal fun PostItemState.toPageUiModel(pageId: Long): PageRsUiModel = when (this) {
41+
is PostItemState.Fresh -> data.toPageUiModel()
42+
is PostItemState.Stale -> data.toPageUiModel()
43+
is PostItemState.FetchingWithData ->
44+
data.toPageUiModel(PageRsDisplayState.FETCHING_WITH_DATA)
45+
is PostItemState.FailedWithData ->
46+
data.toPageUiModel(PageRsDisplayState.FAILED_WITH_DATA)
47+
is PostItemState.Missing,
48+
is PostItemState.Fetching -> PageRsUiModel(
49+
remotePageId = pageId,
50+
title = "",
51+
excerpt = "",
52+
date = "",
53+
displayState = PageRsDisplayState.PLACEHOLDER
54+
)
55+
is PostItemState.Failed -> PageRsUiModel(
56+
remotePageId = pageId,
57+
title = "",
58+
excerpt = "",
59+
date = "",
60+
displayState = PageRsDisplayState.ERROR
61+
)
62+
}
63+
64+
private fun FullEntityAnyPostWithEditContext.toPageUiModel(
65+
displayState: PageRsDisplayState = PageRsDisplayState.NORMAL
66+
): PageRsUiModel {
67+
val page: AnyPostWithEditContext = data
68+
return PageRsUiModel(
69+
remotePageId = page.id,
70+
title = page.title?.raw?.takeIf { it.isNotBlank() }
71+
?: page.title?.rendered
72+
?: "",
73+
excerpt = (
74+
page.excerpt?.raw?.takeIf { it.isNotBlank() }
75+
?: page.excerpt?.rendered
76+
?: ""
77+
).let { HtmlUtils.fastStripHtml(it).trim() },
78+
date = PostRsDateFormatter.format(page.dateGmt, page.status),
79+
lastModified = DateTimeUtils.iso8601UTCFromDate(page.modifiedGmt),
80+
badges = buildList {
81+
if (page.status is PostStatus.Private) {
82+
add(R.string.post_status_post_private)
83+
}
84+
if (page.status is PostStatus.Pending) {
85+
add(R.string.post_status_pending_review)
86+
}
87+
},
88+
displayState = displayState
89+
)
90+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package org.wordpress.android.ui.pagesrs
2+
3+
import android.content.Context
4+
import android.content.Intent
5+
import android.os.Bundle
6+
import androidx.activity.viewModels
7+
import androidx.compose.runtime.collectAsState
8+
import androidx.compose.runtime.getValue
9+
import androidx.lifecycle.Lifecycle
10+
import androidx.lifecycle.lifecycleScope
11+
import androidx.lifecycle.repeatOnLifecycle
12+
import dagger.hilt.android.AndroidEntryPoint
13+
import kotlinx.coroutines.launch
14+
import org.wordpress.android.ui.ActivityLauncher
15+
import org.wordpress.android.ui.compose.theme.AppThemeM3
16+
import org.wordpress.android.ui.main.BaseAppCompatActivity
17+
import org.wordpress.android.ui.pagesrs.screens.PagesRsListScreen
18+
import org.wordpress.android.util.ToastUtils
19+
import org.wordpress.android.util.extensions.setContent
20+
21+
@AndroidEntryPoint
22+
class PagesRsListActivity : BaseAppCompatActivity() {
23+
private val viewModel: PagesRsListViewModel by viewModels()
24+
25+
override fun onCreate(savedInstanceState: Bundle?) {
26+
super.onCreate(savedInstanceState)
27+
28+
observeEvents()
29+
30+
setContent {
31+
val tabStates by viewModel.tabStates.collectAsState()
32+
val isOpeningPage by viewModel.isOpeningPage.collectAsState()
33+
AppThemeM3 {
34+
PagesRsListScreen(
35+
tabStates = tabStates,
36+
isOpeningPage = isOpeningPage,
37+
snackbarMessages = viewModel.snackbarMessages,
38+
onInitTab = viewModel::initTab,
39+
onTabChanged = viewModel::onTabChanged,
40+
onRefreshTab = { tab -> viewModel.refreshTab(tab, isUserRefresh = true) },
41+
onLoadMore = viewModel::loadMorePages,
42+
onNavigateBack = { onBackPressedDispatcher.onBackPressed() },
43+
onPageClick = viewModel::openPage
44+
)
45+
}
46+
}
47+
}
48+
49+
private fun observeEvents() {
50+
lifecycleScope.launch {
51+
repeatOnLifecycle(Lifecycle.State.STARTED) {
52+
viewModel.events.collect { event -> handleEvent(event) }
53+
}
54+
}
55+
}
56+
57+
private fun handleEvent(event: PageRsListEvent) {
58+
when (event) {
59+
is PageRsListEvent.EditPage ->
60+
ActivityLauncher.editPostOrPageForResult(this, event.site, event.page)
61+
is PageRsListEvent.ShowToast -> ToastUtils.showToast(this, event.messageResId)
62+
is PageRsListEvent.Finish -> finish()
63+
}
64+
}
65+
66+
companion object {
67+
fun createIntent(context: Context) = Intent(context, PagesRsListActivity::class.java)
68+
}
69+
}

0 commit comments

Comments
 (0)