Understanding Version Catalogs:The libs.versions.toml file is part of Gradle’s Version Catalogs feature, which simplifies dependency management.If you’re not using Version Catalogs, you can add the dependency directly to your build.gradle.kts:
Now, let’s build a simple Store to fetch and cache posts from our API and cache it for offline access.
1
Define the Data Models
Define the models for a post.
package org.mobilenativefoundation.trails.backend.models@Serializabledata class PostNetworkModel( val id: Int, val creatorId: Int, val caption: String?, val platform: Platform, val createdAt: LocalDateTime, val likesCount: Long, val commentsCount: Long, val sharesCount: Long, val viewsCount: Long, val isSponsored: Boolean, val locationName: String?, val coverUrl: String, val isFavoritedByCurrentUser: Boolean)
Using SqlDelight for Local Database Models:The SQL schema defined using SqlDelight will generate Kotlin models for you. Here’s how the PostEntity class might look after generation:
data class PostEntity( val id: Long, val creator_id: Long, val caption: String?, val created_at: String, val likes_count: Long, val comments_count: Long, val shares_count: Long, val views_count: Long, val is_sponsored: Long, val cover_url: String, val platform: Platform, val location_name: String?, val is_favorited_by_current_user: Long)
For more details on how SqlDelight generates Kotlin classes from SQL schemas, check out the SqlDelight Docs.
2
Create the API Interface
Define and implement an interface for your network calls. In this example, we’ll use Ktor for HTTP requests.
Dependency Injection Context:Trails uses kotlin-inject, a compile-time dependency injection library for Kotlin. The @Inject annotation indicates a class can be injected.
package org.mobilenativefoundation.trails.xplat.lib.rest.apiinterface PostOperations { suspend fun getPost(id: Int): PostNetworkModel suspend fun updatePost(post: PostNetworkModel): Boolean}interface TrailsApi: PostOperations
3
Implement Converters
We need to convert between our network model, domain model, and local database model.
package org.mobilenativefoundation.trails.xplat.lib.market.post.impl.extensionsobject PostExtensions { // Convert from the network model to the domain model. fun PostNetworkModel.asPost(): Post { return Post( id = this.id, creatorId = this.creatorId, caption = this.caption, createdAt = this.createdAt, likesCount = this.likesCount, commentsCount = this.commentsCount, sharesCount = this.sharesCount, viewsCount = this.viewsCount, isSponsored = this.isSponsored, coverURL = this.coverUrl, platform = this.platform.asPlatform(), locationName = this.locationName, isFavoritedByCurrentUser = this.isFavoritedByCurrentUser ) }}
4
Set Up the Store Factory
We’ll use a factory for creating a PostStore instance.
About the TODO() Placeholders:The TODO() placeholders indicate where implementations will be provided in the subsequent steps.
package org.mobilenativefoundation.trails.xplat.lib.market.post.impl.storetypealias PostStore = Store<Int, Post>class PostStoreFactory( private val client: PostOperations, private val trailsDatabase: TrailsDatabase,) { fun create(): PostStore { TODO() } private fun createFetcher(): Fetcher<Int, PostNetworkModel> { TODO() } private fun createSourceOfTruth(): SourceOfTruth<Int, PostEntity, Post> { TODO() } private fun createConverter(): Converter<PostNetworkModel, PostEntity, Post> { TODO() } private fun createUpdater(): Updater<Int, Post, Boolean> { TODO() } private fun createBookkeeper(): Bookkeeper<Int> { TODO() }}
5
Implement the Fetcher
Our Fetcher will interact with the network data source using the PostOperations interface.
package org.mobilenativefoundation.trails.xplat.lib.market.post.impl.storeprivate fun createFetcher(): Fetcher<Int, PostNetworkModel> = Fetcher.of { id -> // Fetch post from the network client.getPost(id) ?: throw IllegalArgumentException("Post with ID $id not found.") }
6
Implement the Source of Truth
Our Source of Truth will delegate to a local SqlDelight database.
package org.mobilenativefoundation.trails.xplat.lib.market.post.impl.storeprivate fun createSourceOfTruth(): SourceOfTruth<Int, PostEntity, Post> = SourceOfTruth.of( reader = { id -> flow { // Query the database for a post emit(trailsDatabase.postQueries.selectPostById(id.toLong())) } }, writer = { _, postEntity -> trailsDatabase.postQueries.insertPost(postEntity) } )
Observing Database Changes Over Time:To ensure your SourceOfTruth observes changes in the database, use asFlow() and appropriate mapping functions.
Our Converter will convert between our network model, local database model, and domain model using the PostExtensions object.
Understanding the Converter’s Role:The Converter bridges the gap between the network model, local database model, and domain model within the Store.While you have extension functions for conversions, the Converter integrates these into the Store’s pipeline, ensuring data flows correctly through each layer.
Now, let’s use the Store to fetch and cache post data for the Trails post detail screen.
1
Create a Post Repository
We’ll create a PostRepository that uses the PostStore to fetch and cache post data. The primary reason for this extra layer is it enables us to extract Store from the domain layer as an implementation detail of the PostRepository. It also enables us to add additional methods and strategies to the PostRepository in the future.
PostRepository is feature agnostic. We define the PostRepository under the
common lib/market/post/api namespace and implement it in the
lib/market/post/impl module to facilitate its reuse across features.
Consumers can depend on the lib/market/post/api module without being exposed
to the implementation details, such as the PostStore.
A market is a composition of stores and systems enabling exchange between
consumers and providers.
Trails is built with a Circuit architecture. Before we can interact with the PostRepository, we need to define the Screen, State, and Event classes.
package org.mobilenativefoundation.trails.xplat.feat.postDetailScreen.apiinterface PostDetailScreen : Screen { sealed interface State : CircuitUiState { data class Loaded( val post: Post, val eventSink: (Event) -> Unit, ) : State data object Loading: State } sealed interface Event : CircuitUiEvent { data object Favorite: Event data object Unfavorite: Event } interface UI : CircuitUI<State> interface Presenter : CircuitPresenter<State>}
3
Implement the Post Detail Presenter
A Circuit Presenter is intended to be the business logic for a screen’s UI and a translation layer in front of the data layer. Our PostDetailScreenPresenter will use the PostRepository to load the post data and update the UI in response to user actions.
package org.mobilenativefoundation.trails.xplat.feat.postDetailScreen.impl@Injectclass PostDetailScreenPresenter( private val postRepository: PostRepository, @Assisted private val postId: Int) : PostDetailScreen.Presenter { @Composable override fun present(): PostDetailScreen.State { var post: Post? by remember { mutableStateOf(null) } LaunchedEffect(postId) { post = postRepository.getPost(postId) } return if (post != null) { PostDetailScreen.State.Loaded(post, eventSink = ::handleEvent) } else { PostDetailScreen.State.Loading } } private fun handleEvent(prevState: PostDetailScreen.State, event: PostDetailScreen.Event) { when (event) { is PostDetailScreen.Event.Favorite -> handleFavorite(prevState) is PostDetailScreen.Event.Unfavorite -> handleUnfavorite(prevState) } } private fun handleFavorite(prevState: PostDetailScreen.State) { val nextPost = postRepository.updatePost( postId = postId, isFavoritedByCurrentUser = true, likesCount = prevState.post.likesCount + 1 ) post = nextPost } private fun handleUnfavorite(prevState: PostDetailScreen.State) { val nextPost = postRepository.updatePost( postId = postId, isFavoritedByCurrentUser = false, likesCount = prevState.post.likesCount - 1 ) post = nextPost }}
4
Display the Post Detail Screen
package org.mobilenativefoundation.trails.xplat.feat.postDetailScreen.impl@Injectclass PostDetailScreenUI : PostDetailScreen.UI { @Composable override fun Content(state: PostDetailScreen.State, modifier: Modifier) { when (state) { is PostDetailScreen.State.Loading -> { LoadingView() } is PostDetailScreen.State.Loaded -> { PostDetailView( post = state.post, onFavorite = { state.eventSink(PostDetailScreen.Event.Favorite) }, onUnfavorite = { state.eventSink(PostDetailScreen.Event.Unfavorite) } ) } } }}