Skip to content

Commit

Permalink
journal: add initial refresh support and paging
Browse files Browse the repository at this point in the history
Due to the potentially large number of journal entries, the Paging library is introduced to avoid holding too many entries in memory.

The `JournalPagingSource` is responsible for grabbing the correct pages of data from Room. It also observes the refresh status and invalidates itself after a refresh event.

The synchronization is done on the first load of the journal screen and then only on demand.
  • Loading branch information
teobaranga committed Oct 30, 2023
1 parent a01acc7 commit e8cc95d
Show file tree
Hide file tree
Showing 9 changed files with 366 additions and 139 deletions.
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ dependencies {
implementation(libs.moshi.converter)
ksp(libs.moshi.kotlin.codegen)

implementation(libs.paging.runtime)
implementation(libs.paging.compose)

implementation(libs.retrofit)
debugImplementation(libs.okhttp.logging.interceptor)

Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/com/teobaranga/monica/data/sync/Synchronizer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.teobaranga.monica.data.sync

interface Synchronizer {
suspend fun sync()

enum class State {
IDLE,
REFRESHING,
}
}
157 changes: 115 additions & 42 deletions app/src/main/java/com/teobaranga/monica/journal/JournalScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
Expand All @@ -22,68 +26,137 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import com.ramcosta.composedestinations.annotation.Destination
import com.teobaranga.monica.dashboard.DashboardSearchBar
import com.teobaranga.monica.journal.model.JournalEntry
import com.teobaranga.monica.ui.avatar.UserAvatar
import com.teobaranga.monica.ui.plus

@JournalNavGraph(start = true)
@Destination
@Composable
fun Journal() {
val viewModel = hiltViewModel<JournalViewModel>()
val uiState by viewModel.uiState.collectAsState()
when (val uiState = uiState) {
null -> {
// TODO: shimmer
Box(modifier = Modifier.fillMaxSize())
}
else -> {
JournalScreen(
uiState = uiState,
)
}
}
val userAvatar by viewModel.userAvatar.collectAsState()
val lazyItems = viewModel.items.collectAsLazyPagingItems()
val isRefreshing by viewModel.isRefreshing.collectAsState()
JournalScreen(
userAvatar = userAvatar,
lazyItems = lazyItems,
onAvatarClick = {
// TODO
},
isRefreshing = isRefreshing,
onRefresh = {
viewModel.refresh()
},
)
}

@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
fun JournalScreen(
uiState: JournalUiState,
userAvatar: UserAvatar?,
onAvatarClick: () -> Unit,
lazyItems: LazyPagingItems<JournalEntry>,
isRefreshing: Boolean,
onRefresh: () -> Unit,
) {
LazyColumn(
val colors = arrayOf(
0.0f to MaterialTheme.colorScheme.background.copy(alpha = 0.78f),
0.75f to MaterialTheme.colorScheme.background.copy(alpha = 0.78f),
1.0f to MaterialTheme.colorScheme.background.copy(alpha = 0.0f),
)
if (userAvatar != null) {
DashboardSearchBar(
modifier = Modifier
.background(Brush.verticalGradient(colorStops = colors))
.statusBarsPadding()
.padding(top = 16.dp, bottom = 20.dp),
userAvatar = userAvatar,
onAvatarClick = onAvatarClick,
)
}
val pullRefreshState = rememberPullRefreshState(
refreshing = isRefreshing,
onRefresh = onRefresh,
)
Box(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(top = SearchBarDefaults.InputFieldHeight + 20.dp)
.zIndex(2f)
) {
PullRefreshIndicator(
modifier = Modifier
.align(Alignment.TopCenter),
refreshing = isRefreshing,
state = pullRefreshState,
)
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = WindowInsets.statusBars.asPaddingValues() + PaddingValues(
top = SearchBarDefaults.InputFieldHeight + 36.dp,
bottom = 20.dp,
),
.pullRefresh(pullRefreshState),
) {
items(
items = uiState.entries,
key = { it.id },
) { journalEntry ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
onClick = {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = WindowInsets.statusBars.asPaddingValues() + PaddingValues(
top = SearchBarDefaults.InputFieldHeight + 36.dp,
bottom = 20.dp,
),
) {
when (lazyItems.loadState.refresh) {
is LoadState.Error -> {
// TODO
},
) {
Column(
modifier = Modifier
.padding(16.dp),
) {
Text(
text = journalEntry.post,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
)
}

is LoadState.Loading,
is LoadState.NotLoading -> {
items(
count = lazyItems.itemCount,
key = {
val journalEntry = lazyItems[it]
journalEntry?.id ?: Int.MIN_VALUE
},
) {
val journalEntry = lazyItems[it]
if (journalEntry != null) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
onClick = {
// TODO
},
) {
Column(
modifier = Modifier
.padding(16.dp),
) {
Text(
text = journalEntry.post,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,73 @@ package com.teobaranga.monica.journal

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.teobaranga.monica.contacts.userAvatar
import com.teobaranga.monica.data.sync.Synchronizer
import com.teobaranga.monica.data.user.UserRepository
import com.teobaranga.monica.journal.data.JournalRepository
import com.teobaranga.monica.journal.data.JournalSynchronizer
import com.teobaranga.monica.user.userAvatar
import com.teobaranga.monica.util.coroutines.Dispatcher
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject

private const val PAGE_SIZE = 15

@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class JournalViewModel @Inject constructor(
journalRepository: JournalRepository,
internal class JournalViewModel @Inject constructor(
private val dispatcher: Dispatcher,
userRepository: UserRepository,
private val journalRepository: JournalRepository,
private val journalSynchronizer: JournalSynchronizer,
) : ViewModel() {

val uiState = journalRepository.getJournalEntries(orderBy = JournalRepository.OrderBy.Date(isAscending = false))
.mapLatest { journalEntries ->
JournalUiState(
entries = journalEntries,
)
val userAvatar = userRepository.me
.mapLatest { me ->
me.contact?.userAvatar ?: me.userAvatar
}
.stateIn(
scope = viewModelScope,
initialValue = null,
started = SharingStarted.WhileSubscribed(5_000),
)

val items = Pager(
config = PagingConfig(pageSize = PAGE_SIZE, enablePlaceholders = false),
pagingSourceFactory = {
journalRepository.getJournalEntries(
orderBy = JournalRepository.OrderBy.Date(isAscending = false),
)
},
)
.flow
.cachedIn(viewModelScope)

val isRefreshing = journalSynchronizer.syncState
.mapLatest { state ->
state == Synchronizer.State.REFRESHING
}
.stateIn(
scope = viewModelScope,
initialValue = false,
started = SharingStarted.WhileSubscribed(5_000),
)

init {
refresh()
}

fun refresh() {
viewModelScope.launch(dispatcher.io) {
journalSynchronizer.sync()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.teobaranga.monica.journal.data

import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.teobaranga.monica.data.sync.Synchronizer
import com.teobaranga.monica.database.OrderBy
import com.teobaranga.monica.journal.database.JournalDao
import com.teobaranga.monica.journal.database.toExternalModel
import com.teobaranga.monica.journal.model.JournalEntry
import com.teobaranga.monica.util.coroutines.Dispatcher
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.reduce
import kotlinx.coroutines.launch


private const val STARTING_KEY = 0

internal class JournalPagingSource @AssistedInject constructor(
dispatcher: Dispatcher,
private val journalDao: JournalDao,
private val journalSynchronizer: JournalSynchronizer,
@Assisted
private val orderBy: JournalRepository.OrderBy,
) : PagingSource<Int, JournalEntry>() {

private val scope = CoroutineScope(SupervisorJob() + dispatcher.io)

init {
scope.launch {
journalSynchronizer.syncState
.reduce { prev, new ->
if (prev == Synchronizer.State.REFRESHING && new == Synchronizer.State.IDLE) {
scope.cancel()
invalidate()
}
new
}
}
}

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, JournalEntry> {
// Start paging with the STARTING_KEY if this is the first load
val start = params.key ?: STARTING_KEY

val entries = getEntries(start, params)

return LoadResult.Page(
data = entries,
// Make sure we don't try to load items behind the STARTING_KEY
prevKey = if (start == 0) null else start - 1,
nextKey = if (entries.isEmpty()) null else start + 1,
)
}

override fun getRefreshKey(state: PagingState<Int, JournalEntry>): Int? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
}

private suspend fun getEntries(start: Int, params: LoadParams<Int>): List<JournalEntry> {
return journalDao.getJournalEntries(
orderBy = with(orderBy) { OrderBy(columnName, isAscending) },
limit = params.loadSize,
offset = start * params.loadSize,
).map { journalEntryEntities ->
journalEntryEntities.map {
it.toExternalModel()
}
}.first()
}


@AssistedFactory
internal interface Factory {
fun create(orderBy: JournalRepository.OrderBy): JournalPagingSource
}
}
Loading

0 comments on commit e8cc95d

Please sign in to comment.