Skip to main content

10 - Camouflage Glue

Concept​

1. Why​

Sinew owns paging state; Camouflage owns the paged list UI. Neither depends on the other:

  • Camouflage's CamoPagedList takes a plain CamoPagedListState and never fetches.
  • Sinew's PagingState knows nothing about UI.

sinew_camouflage is the one small package that knows both. It turns a PagingState<T> into a CamoPagedListState<T>, so a list screen built on both libraries is one line of glue.

2. Shape​

The mapping​

PagingState (view, loadType)itemsloadingendReachederror
Loading, Initial[]Initialfalsenone
Loading, Refresh[]Refreshfalsenone
Loading, Morethe previous itemsMorefalsenone
Done, anythe itemsNone!canLoadMorenone
Failed, Initial or Refresh[]NonefalsePagedError(message, FirstPage)
Failed, Morethe previous itemsNonefalsePagedError(message, NextPage)
RuleWhy
A PagingState starts as Loading + InitialThe list shows its skeleton from the first frame, and never fires onLoadMore on an empty list that hasn't loaded
endReached comes from canLoadMore, which the pager has already normalized for every strategyOne rule for offset, total, cursor and last-item paging
The error message is rendered at conversion time with the app's Localizer: error.displayMessage(localizer)CamoPagedList takes text. The conversion runs when the UI builds, so the text follows the current language.

On the page​

CamoPagedList(
state = state.orders.toCamo(localizer),
itemKey = { it.id },
onLoadMore = { onEvent(EndReached) },
onRetry = { onEvent(RetryTapped) },
onRefresh = { onEvent(Refreshed) },
item = { order, _ -> OrderRow(order) },
)

CamoPagedList fires onLoadMore only when loading is None, endReached is false, and there's no next-page error. The view model's mapEventToAction guards it again with canLoadMore. The double guard is harmless and keeps each side correct on its own.

Versioning​

sinew_camouflage is released in Sinew's lockstep, and declares the Camouflage version range it was tested with. A Camouflage release that changes CamoPagedListState needs a new sinew_camouflage release. Nothing else in Sinew is affected.

3. API​

NameSignature (pseudocode)For
toCamoPagingState<T>.toCamo(localizer: Localizer?): CamoPagedListState<T>the conversion

4. Build steps​

  1. toCamo, test first, with one test per row of the mapping table.
  2. A test that a Failed state renders its message in the localizer's language, and falls back to English without one.
  3. The sample app on each platform: one list screen through Pager → PagingState → toCamo → CamoPagedList, covering first load, next page, a next-page failure with retry, pull to refresh, and the end of the list.

Done when

  • Every row of the mapping table has a test.
  • The sample list screen on each platform runs the full cycle: skeleton, items, more items, retry row, refresh, end.
  • sinew_camouflage is the only Sinew package that depends on Camouflage.

5. Edge cases​

CaseDecided behavior
A Camouflage release changes CamoPagedListStateOnly sinew_camouflage changes, in a new Sinew lockstep release. Its declared version range stops apps from mixing incompatible versions.
An empty first pageDone([]) → items = [], loading = None, endReached = true (an empty page has no next page). CamoPagedList shows its empty state.
A Done state whose meta says there's a next page but the list is shorter than the screenCamoPagedList fires onLoadMore right away, and the pager loads the next page.
The app doesn't use Sinew's LocalizerIt passes null: messages fall back to the English fallback or the server text.
refreshKeepsItems = true in the brand's list themeNot supported by this mapping: Sinew's paged refresh drops the items (Loading(data: null)), so the list shows the skeleton. A product that wants to keep items on refresh needs a different paging rule, which is a Sinew change, not a glue option.

Implementation​

1. Why​

sinew-camouflage maps Sinew's PagingState<T> to Camouflage's CamoPagedListState<T>. On Kotlin it's one extension function, plus a composable convenience that remembers the localizer.

2. Shape​

public fun <T> PagingState<T>.toCamo(localizer: Localizer?): CamoPagedListState<T> = when (val v = view) {
is ViewState.Loading -> CamoPagedListState(
items = if (loadType == LoadType.More) v.data?.items.orEmpty() else emptyList(),
loading = when (loadType) { LoadType.Initial -> PagedLoad.Initial; LoadType.Refresh -> PagedLoad.Refresh; LoadType.More -> PagedLoad.More },
)
is ViewState.Done -> CamoPagedListState(items = v.data.items, loading = PagedLoad.None, endReached = !canLoadMore)
is ViewState.Failed -> CamoPagedListState(
items = if (loadType == LoadType.More) v.data?.items.orEmpty() else emptyList(),
error = PagedError(v.error.displayMessage(localizer), if (loadType == LoadType.More) PagedErrorScope.NextPage else PagedErrorScope.FirstPage),
)
}

@Composable
public fun <T> PagingState<T>.toCamo(): CamoPagedListState<T> = toCamo(rememberSinewLocalizer())

On the screen:

CamoPagedList(
state = state.orders.toCamo(),
itemKey = { it.id },
onLoadMore = { onEvent(OrderListEvent.EndReached) },
onRetry = { onEvent(OrderListEvent.RetryTapped) },
onRefresh = { onEvent(OrderListEvent.Refreshed) },
) { order, _ -> OrderRow(order) }

3. API​

NameKotlin
toCamopublic fun <T> PagingState<T>.toCamo(localizer: Localizer?): CamoPagedListState<T> and the @Composable overload

4. Build steps​

  1. toCamo, with one commonTest test per row of the base mapping table.
  2. The @Composable overload, tested with runComposeUiTest in English and Indonesian.
  3. The sample list screen in :sample:shared, on all four targets.

Done when

  • The sample list screen runs the full cycle (skeleton, items, more, retry, refresh, end) on Android, iOS, desktop and web.

5. Edge cases​

CaseDecided behavior
Camouflage's PagedErrorScope naming differs in the released APIThe glue follows Camouflage's released names. Only this file changes.
Decided by default (revisit during implementation)
  • A @Composable overload that remembers Sinew's localizer, so the common case needs no argument.