Skip to content

Commit 2e2048b

Browse files
committed
Expose ListInfo via UniFFI and rename state observers to listInfo observers
Complete the ListInfo refactoring through the entire stack: Rust: - Add uniffi::Record to ListInfo for Kotlin export - Add list_info() method to PostMetadataCollectionWithEditContext - Rename is_relevant_state_update() to is_relevant_list_info_update() - Check both ListMetadata and ListMetadataState tables for list info changes Kotlin wrapper: - Add ListInfo.hasMorePages and ListInfo.isSyncing extensions - Expose listInfo() method, remove individual accessors - Rename stateObservers to listInfoObservers Kotlin ViewModel: - Store ListInfo directly in state instead of separate fields - Derive isSyncing, hasMorePages, currentPage from listInfo - Use addListInfoObserver for pagination + state changes Observer split is now: - Data observers: entity tables + list_metadata_items - ListInfo observers: list_metadata + list_metadata_state
1 parent 1d709e7 commit 2e2048b

File tree

6 files changed

+115
-101
lines changed

6 files changed

+115
-101
lines changed

native/kotlin/api/kotlin/src/main/kotlin/rs/wordpress/cache/kotlin/ObservableMetadataCollection.kt

Lines changed: 49 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
11
package rs.wordpress.cache.kotlin
22

3+
import uniffi.wp_mobile.ListInfo
34
import uniffi.wp_mobile.PostMetadataCollectionItem
45
import uniffi.wp_mobile.PostMetadataCollectionWithEditContext
56
import uniffi.wp_mobile.SyncResult
67
import uniffi.wp_mobile_cache.ListState
78
import uniffi.wp_mobile_cache.UpdateHook
89
import java.util.concurrent.CopyOnWriteArrayList
910

11+
/**
12+
* Check if there are more pages to load.
13+
*
14+
* Returns `true` if:
15+
* - Total pages is unknown (assume more)
16+
* - Current page is less than total pages
17+
*/
18+
val ListInfo.hasMorePages: Boolean
19+
get() = totalPages?.let { currentPage < it } ?: true
20+
21+
/**
22+
* Check if a sync operation is in progress.
23+
*/
24+
val ListInfo.isSyncing: Boolean
25+
get() = state == ListState.FETCHING_FIRST_PAGE || state == ListState.FETCHING_NEXT_PAGE
26+
1027
/**
1128
* Create an observable metadata collection that notifies observers when data changes.
1229
*
@@ -69,7 +86,7 @@ class ObservableMetadataCollection(
6986
private val collection: PostMetadataCollectionWithEditContext
7087
) : AutoCloseable {
7188
private val dataObservers = CopyOnWriteArrayList<() -> Unit>()
72-
private val stateObservers = CopyOnWriteArrayList<() -> Unit>()
89+
private val listInfoObservers = CopyOnWriteArrayList<() -> Unit>()
7390

7491
/**
7592
* Add an observer for data changes (list contents changed).
@@ -85,30 +102,28 @@ class ObservableMetadataCollection(
85102
}
86103

87104
/**
88-
* Add an observer for state changes (sync status changed).
105+
* Add an observer for list info changes (pagination or sync state changed).
89106
*
90-
* State observers are notified when the sync state changes:
91-
* - Idle -> FetchingFirstPage (refresh started)
92-
* - Idle -> FetchingNextPage (load more started)
93-
* - Fetching* -> Idle (sync completed)
94-
* - Fetching* -> Error (sync failed)
107+
* ListInfo observers are notified when:
108+
* - Pagination info changes (current page, total pages updated after fetch)
109+
* - Sync state changes (Idle -> FetchingFirstPage, etc.)
95110
*
96-
* Use this for updating loading indicators in the UI.
111+
* Use this for updating pagination display and loading indicators in the UI.
97112
*/
98-
fun addStateObserver(observer: () -> Unit) {
99-
stateObservers.add(observer)
113+
fun addListInfoObserver(observer: () -> Unit) {
114+
listInfoObservers.add(observer)
100115
}
101116

102117
/**
103-
* Add an observer for both data and state changes.
118+
* Add an observer for both data and list info changes.
104119
*
105120
* This is a convenience method that registers the observer for both
106-
* data and state updates. Use this when you want to refresh the entire
121+
* data and list info updates. Use this when you want to refresh the entire
107122
* UI on any change.
108123
*/
109124
fun addObserver(observer: () -> Unit) {
110125
dataObservers.add(observer)
111-
stateObservers.add(observer)
126+
listInfoObservers.add(observer)
112127
}
113128

114129
/**
@@ -119,18 +134,18 @@ class ObservableMetadataCollection(
119134
}
120135

121136
/**
122-
* Remove a state observer.
137+
* Remove a list info observer.
123138
*/
124-
fun removeStateObserver(observer: () -> Unit) {
125-
stateObservers.remove(observer)
139+
fun removeListInfoObserver(observer: () -> Unit) {
140+
listInfoObservers.remove(observer)
126141
}
127142

128143
/**
129-
* Remove an observer from both data and state lists.
144+
* Remove an observer from both data and list info lists.
130145
*/
131146
fun removeObserver(observer: () -> Unit) {
132147
dataObservers.remove(observer)
133-
stateObservers.remove(observer)
148+
listInfoObservers.remove(observer)
134149
}
135150

136151
/**
@@ -181,49 +196,37 @@ class ObservableMetadataCollection(
181196
suspend fun loadNextPage(): SyncResult = collection.loadNextPage()
182197

183198
/**
184-
* Check if there are more pages to load.
185-
*/
186-
fun hasMorePages(): Boolean = collection.hasMorePages()
187-
188-
/**
189-
* Get the current page number (0 = not loaded yet).
190-
*/
191-
fun currentPage(): UInt = collection.currentPage()
192-
193-
/**
194-
* Get the total number of pages, if known.
195-
*/
196-
fun totalPages(): UInt? = collection.totalPages()
197-
198-
/**
199-
* Get the current sync state for this collection.
199+
* Get combined list info (pagination + sync state) in a single query.
200+
*
201+
* Returns `null` if the list hasn't been created yet.
200202
*
201-
* Returns:
202-
* - [ListState.IDLE] - No sync in progress
203-
* - [ListState.FETCHING_FIRST_PAGE] - Refresh in progress
204-
* - [ListState.FETCHING_NEXT_PAGE] - Load more in progress
205-
* - [ListState.ERROR] - Last sync failed
203+
* The returned [ListInfo] contains:
204+
* - `state`: Current sync state (IDLE, FETCHING_FIRST_PAGE, FETCHING_NEXT_PAGE, ERROR)
205+
* - `errorMessage`: Error message if state is ERROR
206+
* - `currentPage`: Current page number (0 = not loaded yet)
207+
* - `totalPages`: Total pages if known
208+
* - `totalItems`: Total items if known
209+
* - `perPage`: Items per page setting
206210
*
207-
* Use this with state observers to show loading indicators in the UI.
208-
* This is a suspend function that reads from the database on a background thread.
211+
* Use [ListInfo.hasMorePages] extension to check if more pages are available.
209212
*/
210-
suspend fun syncState(): ListState = collection.syncState()
213+
fun listInfo(): ListInfo? = collection.listInfo()
211214

212215
/**
213216
* Internal method called by DatabaseChangeNotifier when a database update occurs.
214217
*
215218
* Checks relevance and notifies appropriate observers:
216219
* - Data updates -> dataObservers
217-
* - State updates -> stateObservers
220+
* - List info updates -> listInfoObservers
218221
*/
219222
internal fun notifyIfRelevant(hook: UpdateHook) {
220223
val isDataRelevant = collection.isRelevantDataUpdate(hook)
221-
val isStateRelevant = collection.isRelevantStateUpdate(hook)
224+
val isListInfoRelevant = collection.isRelevantListInfoUpdate(hook)
222225
if (isDataRelevant) {
223226
dataObservers.forEach { it() }
224227
}
225-
if (isStateRelevant) {
226-
stateObservers.forEach { it() }
228+
if (isListInfoRelevant) {
229+
listInfoObservers.forEach { it() }
227230
}
228231
}
229232

native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postmetadatacollection/PostMetadataCollectionScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ fun LoadNextPageCard(
400400
) {
401401
Text("Load Next Page")
402402
}
403-
} else if (state.currentPage > 0u) {
403+
} else if (state.currentPage > 0L) {
404404
Text(
405405
text = "All pages loaded",
406406
style = MaterialTheme.typography.caption,

native/kotlin/example/composeApp/src/commonMain/kotlin/rs/wordpress/example/shared/ui/postmetadatacollection/PostMetadataCollectionViewModel.kt

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ import kotlinx.coroutines.flow.asStateFlow
1010
import kotlinx.coroutines.launch
1111
import rs.wordpress.cache.kotlin.ObservableMetadataCollection
1212
import rs.wordpress.cache.kotlin.getObservablePostMetadataCollectionWithEditContext
13+
import rs.wordpress.cache.kotlin.hasMorePages
14+
import rs.wordpress.cache.kotlin.isSyncing
1315
import uniffi.wp_api.PostEndpointType
1416
import uniffi.wp_api.PostListParams
17+
import uniffi.wp_mobile.ListInfo
1518
import uniffi.wp_mobile.PostItemState
1619
import uniffi.wp_mobile.PostMetadataCollectionItem
1720
import uniffi.wp_mobile.SyncResult
@@ -23,22 +26,28 @@ import uniffi.wp_mobile_cache.ListState
2326
*/
2427
data class PostMetadataCollectionState(
2528
val currentParams: PostListParams,
26-
val currentPage: UInt = 0u,
27-
val totalPages: UInt? = null,
29+
val listInfo: ListInfo? = null,
2830
val lastSyncResult: SyncResult? = null,
29-
val lastError: String? = null,
30-
val syncState: ListState = ListState.IDLE
31+
val lastError: String? = null
3132
) {
3233
/**
3334
* Whether a sync operation is in progress.
34-
* Derived from syncState - the single source of truth from the database.
35+
* Derived from listInfo.state - the single source of truth from the database.
3536
*/
3637
val isSyncing: Boolean
37-
get() = syncState == ListState.FETCHING_FIRST_PAGE ||
38-
syncState == ListState.FETCHING_NEXT_PAGE
38+
get() = listInfo?.isSyncing ?: false
3939

4040
val hasMorePages: Boolean
41-
get() = totalPages?.let { currentPage < it } ?: true
41+
get() = listInfo?.hasMorePages ?: true
42+
43+
val currentPage: Long
44+
get() = listInfo?.currentPage ?: 0L
45+
46+
val totalPages: Long?
47+
get() = listInfo?.totalPages
48+
49+
val syncState: ListState
50+
get() = listInfo?.state ?: ListState.IDLE
4251

4352
val filterDisplayName: String
4453
get() {
@@ -130,29 +139,23 @@ class PostMetadataCollectionViewModel(
130139
observableCollection?.close()
131140
createObservableCollection(newParams)
132141

133-
// Read persisted pagination state from database (sync values)
134-
val collection = observableCollection
142+
// Read persisted state from database (single query)
135143
_state.value = PostMetadataCollectionState(
136144
currentParams = newParams,
137-
currentPage = collection?.currentPage() ?: 0u,
138-
totalPages = collection?.totalPages(),
145+
listInfo = observableCollection?.listInfo(),
139146
lastSyncResult = null,
140-
lastError = null,
141-
syncState = ListState.IDLE
147+
lastError = null
142148
)
143149

144-
// Load items and syncState (async)
145-
viewModelScope.launch(Dispatchers.Default) {
146-
loadItemsFromCollectionInternal()
147-
updateSyncState()
148-
}
150+
// Load items (async)
151+
loadItemsFromCollection()
149152
}
150153

151154
/**
152155
* Refresh the collection (fetch page 1, sync missing/stale)
153156
*
154157
* Note: syncState is managed by the database and observed via state observer.
155-
* We don't manually toggle isSyncing - it's derived from syncState.
158+
* We don't manually toggle isSyncing - it's derived from listInfo.state.
156159
*/
157160
fun refresh() {
158161
if (_state.value.isSyncing) return
@@ -165,8 +168,7 @@ class PostMetadataCollectionViewModel(
165168
val result = collection.refresh()
166169

167170
_state.value = _state.value.copy(
168-
currentPage = collection.currentPage(),
169-
totalPages = collection.totalPages(),
171+
listInfo = collection.listInfo(),
170172
lastSyncResult = result,
171173
lastError = null
172174
)
@@ -182,14 +184,14 @@ class PostMetadataCollectionViewModel(
182184
* Load the next page of items
183185
*
184186
* Note: syncState is managed by the database and observed via state observer.
185-
* We don't manually toggle isSyncing - it's derived from syncState.
187+
* We don't manually toggle isSyncing - it's derived from listInfo.state.
186188
*/
187189
fun loadNextPage() {
188190
if (_state.value.isSyncing) return
189191
if (!_state.value.hasMorePages) return
190192

191193
// If no pages have been loaded yet, do a refresh instead
192-
if (_state.value.currentPage == 0u) {
194+
if (_state.value.currentPage == 0L) {
193195
refresh()
194196
return
195197
}
@@ -202,8 +204,7 @@ class PostMetadataCollectionViewModel(
202204
val result = collection.loadNextPage()
203205

204206
_state.value = _state.value.copy(
205-
currentPage = collection.currentPage(),
206-
totalPages = collection.totalPages(),
207+
listInfo = collection.listInfo(),
207208
lastSyncResult = result,
208209
lastError = null
209210
)
@@ -223,29 +224,26 @@ class PostMetadataCollectionViewModel(
223224
)
224225

225226
// Data observer: refresh list contents when data changes
226-
// Note: Must dispatch to coroutine since loadItems() is a suspend function
227227
observable.addDataObserver {
228228
viewModelScope.launch(Dispatchers.Default) {
229229
loadItemsFromCollectionInternal()
230230
}
231231
}
232232

233-
// State observer: update sync state indicator when state changes
234-
// Note: Must dispatch to coroutine since syncState() is a suspend function
235-
observable.addStateObserver {
233+
// ListInfo observer: update listInfo when pagination or sync state changes
234+
observable.addListInfoObserver {
236235
viewModelScope.launch(Dispatchers.Default) {
237-
updateSyncState()
236+
updateListInfo()
238237
}
239238
}
240239

241240
observableCollection = observable
242241
}
243242

244-
private suspend fun updateSyncState() {
245-
val collection = observableCollection ?: return
246-
val newSyncState = collection.syncState()
247-
println("[ViewModel] updateSyncState: new state = $newSyncState")
248-
_state.value = _state.value.copy(syncState = newSyncState)
243+
private fun updateListInfo() {
244+
val newListInfo = observableCollection?.listInfo()
245+
println("[ViewModel] updateListInfo: new state = ${newListInfo?.state}")
246+
_state.value = _state.value.copy(listInfo = newListInfo)
249247
}
250248

251249
private suspend fun loadItemsFromCollectionInternal() {

wp_mobile/src/collection/post_metadata_collection.rs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ use crate::{
99
collection::{CollectionError, FetchError},
1010
service::posts::PostService,
1111
sync::{
12-
EntityState, MetadataCollection, PersistentPostMetadataFetcherWithEditContext, SyncResult,
12+
EntityState, ListInfo, MetadataCollection, PersistentPostMetadataFetcherWithEditContext,
13+
SyncResult,
1314
},
1415
};
1516

@@ -191,6 +192,15 @@ impl PostMetadataCollectionWithEditContext {
191192
self.collection.load_next_page().await
192193
}
193194

195+
/// Get combined list info (pagination + sync state) in a single query.
196+
///
197+
/// Returns `None` if the list hasn't been created yet.
198+
/// Use this instead of calling `current_page()`, `total_pages()`, `sync_state()`
199+
/// separately to avoid multiple database queries.
200+
pub fn list_info(&self) -> Option<ListInfo> {
201+
self.collection.list_info()
202+
}
203+
194204
/// Check if there are more pages to load.
195205
pub fn has_more_pages(&self) -> bool {
196206
self.collection.has_more_pages()
@@ -244,14 +254,15 @@ impl PostMetadataCollectionWithEditContext {
244254
self.collection.is_relevant_data_update(hook)
245255
}
246256

247-
/// Check if a database update affects this collection's sync state.
257+
/// Check if a database update affects this collection's list info (pagination + state).
248258
///
249-
/// Returns `true` if the update is to the ListMetadataState table
250-
/// for this collection's specific list.
259+
/// Returns `true` if the update is to:
260+
/// - `ListMetadata` table (pagination info changed)
261+
/// - `ListMetadataState` table (sync state changed)
251262
///
252-
/// Use this for state observers that should update loading indicators.
253-
pub fn is_relevant_state_update(&self, hook: &UpdateHook) -> bool {
254-
self.collection.is_relevant_state_update(hook)
263+
/// Use this for listInfo observers that should update pagination display and loading indicators.
264+
pub fn is_relevant_list_info_update(&self, hook: &UpdateHook) -> bool {
265+
self.collection.is_relevant_list_info_update(hook)
255266
}
256267

257268
/// Get the API parameters for this collection.

wp_mobile/src/sync/list_metadata_reader.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use wp_mobile_cache::list_metadata::ListState;
44
/// Combined list information: pagination + sync state.
55
///
66
/// Returned by a single JOIN query on `list_metadata` + `list_metadata_state` tables.
7-
#[derive(Debug, Clone, PartialEq, Eq)]
7+
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
88
pub struct ListInfo {
99
/// Current sync state (Idle, FetchingFirstPage, FetchingNextPage, Error)
1010
pub state: ListState,

0 commit comments

Comments
 (0)