Add saved lists feature

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
This commit is contained in:
Aditya Wasan 2020-10-18 16:03:07 +05:30
parent 3bd3f2dff8
commit d6d82248a8
8 changed files with 185 additions and 39 deletions

View file

@ -3,18 +3,34 @@ package dev.msfjarvis.lobsters
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.animate
import androidx.compose.foundation.Icon
import androidx.compose.foundation.Text
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumnForIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.IconToggleButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Providers
import androidx.compose.runtime.State
import androidx.compose.runtime.ambientOf
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.setContent
@ -23,8 +39,10 @@ import androidx.compose.ui.unit.dp
import dagger.hilt.android.AndroidEntryPoint
import dev.msfjarvis.lobsters.compose.utils.IconResource
import dev.msfjarvis.lobsters.data.LobstersViewModel
import dev.msfjarvis.lobsters.model.LobstersPost
import dev.msfjarvis.lobsters.ui.LobstersItem
import dev.msfjarvis.lobsters.ui.LobstersTheme
import dev.msfjarvis.lobsters.ui.savedTitleColor
import dev.msfjarvis.lobsters.urllauncher.UrlLauncher
import javax.inject.Inject
@ -52,40 +70,111 @@ fun LobstersApp(
viewModel: LobstersViewModel
) {
val urlLauncher = UrlLauncherAmbient.current
val state = viewModel.posts.collectAsState()
val lastIndex = state.value.lastIndex
val hottestPostsState = viewModel.posts.collectAsState()
val savedPostsState = viewModel.savedPosts.collectAsState()
val lastIndex = hottestPostsState.value.lastIndex
val showSaved = remember { mutableStateOf(false) }
Scaffold(
topBar = { LobsterTopAppbar(showSaved) },
bodyContent = {
if (state.value.isEmpty()) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
IconResource(R.drawable.ic_sync_problem_24px)
Text(stringResource(R.string.loading))
}
if ((!showSaved.value && hottestPostsState.value.isEmpty()) || (showSaved.value && savedPostsState.value.isEmpty())) {
LobsterEmptyList(showSaved)
} else {
LobsterList(
showSaved,
savedPostsState,
hottestPostsState,
lastIndex,
viewModel,
urlLauncher
)
}
},
floatingActionButton = { LobsterFAB(showSaved, viewModel) },
)
}
@Composable
private fun LobsterFAB(
showSaved: MutableState<Boolean>,
viewModel: LobstersViewModel
) {
val animatedYOffset = animate(if (showSaved.value) 100.dp else 0.dp)
FloatingActionButton(
onClick = { viewModel.refreshPosts() },
modifier = Modifier.offset(y = animatedYOffset)
) {
IconResource(resourceId = R.drawable.ic_refresh_24px)
}
}
@Composable
private fun LobsterList(
showSaved: MutableState<Boolean>,
savedPostsState: State<List<LobstersPost>>,
hottestPostsState: State<List<LobstersPost>>,
lastIndex: Int,
viewModel: LobstersViewModel,
urlLauncher: UrlLauncher
) {
val hottestPostsListState = rememberLazyListState()
val savedPostsListState = rememberLazyListState()
LazyColumnForIndexed(
items = state.value,
items = if (showSaved.value) savedPostsState.value else hottestPostsState.value,
state = if (showSaved.value) savedPostsListState else hottestPostsListState,
modifier = Modifier.padding(horizontal = 8.dp)
) { index, item ->
if (lastIndex == index) {
if (lastIndex == index && !showSaved.value) {
viewModel.getMorePosts()
}
LobstersItem(
item,
linkOpenAction = { post -> urlLauncher.launch(post.url.ifEmpty { post.commentsUrl }) },
commentOpenAction = { post -> urlLauncher.launch(post.commentsUrl) },
saveAction = { post -> if (!showSaved.value) viewModel.savePost(post) },
)
}
}
@Composable
private fun LobsterEmptyList(showSaved: MutableState<Boolean>) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!showSaved.value) {
IconResource(R.drawable.ic_sync_problem_24px, modifier = Modifier.padding(16.dp))
Text(stringResource(R.string.loading))
} else {
Icon(Icons.Default.FavoriteBorder, tint = savedTitleColor, modifier = Modifier.padding(16.dp))
Text(stringResource(R.string.no_saved_posts))
}
},
floatingActionButton = {
FloatingActionButton(onClick = { viewModel.refreshPosts() }) {
IconResource(resourceId = R.drawable.ic_refresh_24px)
}
},
}
@Composable
private fun LobsterTopAppbar(showSaved: MutableState<Boolean>) {
TopAppBar {
Box(modifier = Modifier.fillMaxWidth()) {
Text(
text = if (showSaved.value) "Saved" else "Home",
modifier = Modifier.padding(16.dp).align(Alignment.CenterStart),
style = MaterialTheme.typography.h6,
)
IconToggleButton(
checked = showSaved.value,
onCheckedChange = { showSaved.value = !showSaved.value },
modifier = Modifier.padding(8.dp).align(Alignment.CenterEnd),
) {
Icon(
asset = if (showSaved.value) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
tint = savedTitleColor,
)
}
}
}
}

View file

@ -10,6 +10,7 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.net.SocketTimeoutException
import java.net.UnknownHostException
@ -20,14 +21,16 @@ class LobstersViewModel @ViewModelInject constructor(
) : ViewModel() {
private var apiPage = 1
private val _posts = MutableStateFlow<List<LobstersPost>>(emptyList())
private val dao = database.postsDao()
private val _savedPosts = MutableStateFlow<List<LobstersPost>>(emptyList())
private val postsDao = database.postsDao()
private val savedPostsDao = database.savedPostsDao()
private val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
when (throwable) {
// Swallow known network errors that can be recovered from.
is UnknownHostException, is SocketTimeoutException -> {
if (_posts.value.isEmpty()) {
viewModelScope.launch {
dao.loadPosts().collectLatest { _posts.value = it }
postsDao.loadPosts().collectLatest { _posts.value = it }
}
}
}
@ -35,9 +38,17 @@ class LobstersViewModel @ViewModelInject constructor(
}
}
val posts: StateFlow<List<LobstersPost>> get() = _posts
val savedPosts: StateFlow<List<LobstersPost>> get() = _savedPosts
init {
getMorePostsInternal(true)
getSavedPosts()
}
private fun getSavedPosts() {
viewModelScope.launch {
savedPostsDao.loadPosts().collectLatest { _savedPosts.value = it }
}
}
fun getMorePosts() {
@ -54,12 +65,24 @@ class LobstersViewModel @ViewModelInject constructor(
val newPosts = lobstersApi.getHottestPosts(apiPage)
if (firstLoad) {
_posts.value = newPosts
dao.deleteAllPosts()
postsDao.deleteAllPosts()
} else {
_posts.value += newPosts
}
apiPage += 1
dao.insertPosts(*_posts.value.toTypedArray())
postsDao.insertPosts(*_posts.value.toTypedArray())
}
}
fun savePost(post: LobstersPost) {
viewModelScope.launch {
val tempList = _posts.value
_posts.value = tempList.map {
if (it.shortId == post.shortId) it.isLiked = true
it
}
savedPostsDao.insertPosts(post)
getSavedPosts()
}
}
}

View file

@ -1,5 +1,6 @@
package dev.msfjarvis.lobsters.ui
import androidx.compose.animation.animate
import androidx.compose.foundation.Text
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -13,6 +14,8 @@ import androidx.compose.foundation.lazy.LazyColumnFor
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -31,18 +34,27 @@ fun LazyItemScope.LobstersItem(
modifier: Modifier = Modifier,
linkOpenAction: (LobstersPost) -> Unit,
commentOpenAction: (LobstersPost) -> Unit,
saveAction: (LobstersPost) -> Unit,
) {
val liked = remember { mutableStateOf(false) }
val titleColor = if (post.isLiked || liked.value) savedTitleColor else titleColor
Column(
modifier = modifier
.fillParentMaxWidth()
.clickable(
onClick = { linkOpenAction.invoke(post) },
onLongClick = { commentOpenAction.invoke(post) }
onLongClick = { commentOpenAction.invoke(post) },
onDoubleClick = {
post.isLiked = true
liked.value = true
saveAction.invoke(post)
},
),
) {
Text(
text = post.title,
color = Color(0xFF7395D9),
color = titleColor,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 4.dp)
)
@ -106,11 +118,15 @@ fun PreviewLobstersItem() {
null,
null,
),
listOf("openbsd")
listOf("openbsd"),
)
LobstersTheme {
LazyColumnFor(items = listOf(post)) { item ->
LobstersItem(post = item, linkOpenAction = {}, commentOpenAction = {})
LobstersItem(
post = item,
linkOpenAction = {},
commentOpenAction = {},
saveAction = {})
}
}
}

View file

@ -5,6 +5,9 @@ import androidx.compose.material.darkColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
val titleColor = Color(0xFF7395D9)
val savedTitleColor = Color(0xFFD97373)
val darkColors = darkColors(
primary = Color.White,
secondary = Color(0xFF6C0000),

View file

@ -1,4 +1,5 @@
<resources>
<string name="app_name">lobste.rs</string>
<string name="loading">Loading posts…</string>
<string name="no_saved_posts">You don\'t have any saved posts</string>
</resources>

View file

@ -6,6 +6,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import dev.msfjarvis.lobsters.data.model.LobstersEntity
import dev.msfjarvis.lobsters.model.LobstersPost
import kotlinx.coroutines.flow.Flow
@ -15,6 +16,15 @@ abstract class PostsDao {
@Query("SELECT * FROM lobsters_posts")
abstract fun loadPosts(): Flow<List<LobstersPost>>
@Update
suspend fun updatePost(vararg posts: LobstersPost) {
updatePosts(posts.map { LobstersEntity(it) })
}
@Update(onConflict = OnConflictStrategy.IGNORE)
protected abstract suspend fun updatePosts(posts: List<LobstersEntity>)
@Transaction
open suspend fun insertPosts(vararg posts: LobstersPost) {
insertPosts(posts.map { LobstersEntity(it) })

View file

@ -4,12 +4,14 @@ import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import dev.msfjarvis.lobsters.data.model.LobstersEntity
import dev.msfjarvis.lobsters.data.model.SavedLobstersEntity
@Database(
entities = [
LobstersEntity::class,
SavedLobstersEntity::class
],
version = 1,
version = 2,
exportSchema = false,
)
@TypeConverters(
@ -17,4 +19,5 @@ import dev.msfjarvis.lobsters.data.model.LobstersEntity
)
abstract class PostsDatabase : RoomDatabase() {
abstract fun postsDao(): PostsDao
abstract fun savedPostsDao(): SavedPostsDao
}

View file

@ -22,5 +22,6 @@ class LobstersPost(
val commentsUrl: String,
@Json(name = "submitter_user")
val submitterUser: Submitter,
val tags: List<String>
val tags: List<String>,
var isLiked: Boolean = false,
)