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 android.os.Bundle
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.animate
import androidx.compose.foundation.Icon
import androidx.compose.foundation.Text import androidx.compose.foundation.Text
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize 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.layout.padding
import androidx.compose.foundation.lazy.LazyColumnForIndexed import androidx.compose.foundation.lazy.LazyColumnForIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.FloatingActionButton import androidx.compose.material.FloatingActionButton
import androidx.compose.material.IconToggleButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold 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.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Providers import androidx.compose.runtime.Providers
import androidx.compose.runtime.State
import androidx.compose.runtime.ambientOf import androidx.compose.runtime.ambientOf
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.setContent import androidx.compose.ui.platform.setContent
@ -23,8 +39,10 @@ import androidx.compose.ui.unit.dp
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.msfjarvis.lobsters.compose.utils.IconResource import dev.msfjarvis.lobsters.compose.utils.IconResource
import dev.msfjarvis.lobsters.data.LobstersViewModel import dev.msfjarvis.lobsters.data.LobstersViewModel
import dev.msfjarvis.lobsters.model.LobstersPost
import dev.msfjarvis.lobsters.ui.LobstersItem import dev.msfjarvis.lobsters.ui.LobstersItem
import dev.msfjarvis.lobsters.ui.LobstersTheme import dev.msfjarvis.lobsters.ui.LobstersTheme
import dev.msfjarvis.lobsters.ui.savedTitleColor
import dev.msfjarvis.lobsters.urllauncher.UrlLauncher import dev.msfjarvis.lobsters.urllauncher.UrlLauncher
import javax.inject.Inject import javax.inject.Inject
@ -52,40 +70,111 @@ fun LobstersApp(
viewModel: LobstersViewModel viewModel: LobstersViewModel
) { ) {
val urlLauncher = UrlLauncherAmbient.current val urlLauncher = UrlLauncherAmbient.current
val state = viewModel.posts.collectAsState() val hottestPostsState = viewModel.posts.collectAsState()
val lastIndex = state.value.lastIndex val savedPostsState = viewModel.savedPosts.collectAsState()
val lastIndex = hottestPostsState.value.lastIndex
val showSaved = remember { mutableStateOf(false) }
Scaffold( Scaffold(
topBar = { LobsterTopAppbar(showSaved) },
bodyContent = { bodyContent = {
if (state.value.isEmpty()) { if ((!showSaved.value && hottestPostsState.value.isEmpty()) || (showSaved.value && savedPostsState.value.isEmpty())) {
Column( LobsterEmptyList(showSaved)
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
IconResource(R.drawable.ic_sync_problem_24px)
Text(stringResource(R.string.loading))
}
} else { } else {
LazyColumnForIndexed( LobsterList(
items = state.value, showSaved,
modifier = Modifier.padding(horizontal = 8.dp) savedPostsState,
) { index, item -> hottestPostsState,
if (lastIndex == index) { lastIndex,
viewModel.getMorePosts() viewModel,
} urlLauncher
LobstersItem( )
item,
linkOpenAction = { post -> urlLauncher.launch(post.url.ifEmpty { post.commentsUrl }) },
commentOpenAction = { post -> urlLauncher.launch(post.commentsUrl) },
)
}
}
},
floatingActionButton = {
FloatingActionButton(onClick = { viewModel.refreshPosts() }) {
IconResource(resourceId = R.drawable.ic_refresh_24px)
} }
}, },
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 = 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 && !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))
}
}
}
@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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.UnknownHostException import java.net.UnknownHostException
@ -20,14 +21,16 @@ class LobstersViewModel @ViewModelInject constructor(
) : ViewModel() { ) : ViewModel() {
private var apiPage = 1 private var apiPage = 1
private val _posts = MutableStateFlow<List<LobstersPost>>(emptyList()) 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 -> private val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
when (throwable) { when (throwable) {
// Swallow known network errors that can be recovered from. // Swallow known network errors that can be recovered from.
is UnknownHostException, is SocketTimeoutException -> { is UnknownHostException, is SocketTimeoutException -> {
if (_posts.value.isEmpty()) { if (_posts.value.isEmpty()) {
viewModelScope.launch { 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 posts: StateFlow<List<LobstersPost>> get() = _posts
val savedPosts: StateFlow<List<LobstersPost>> get() = _savedPosts
init { init {
getMorePostsInternal(true) getMorePostsInternal(true)
getSavedPosts()
}
private fun getSavedPosts() {
viewModelScope.launch {
savedPostsDao.loadPosts().collectLatest { _savedPosts.value = it }
}
} }
fun getMorePosts() { fun getMorePosts() {
@ -54,12 +65,24 @@ class LobstersViewModel @ViewModelInject constructor(
val newPosts = lobstersApi.getHottestPosts(apiPage) val newPosts = lobstersApi.getHottestPosts(apiPage)
if (firstLoad) { if (firstLoad) {
_posts.value = newPosts _posts.value = newPosts
dao.deleteAllPosts() postsDao.deleteAllPosts()
} else { } else {
_posts.value += newPosts _posts.value += newPosts
} }
apiPage += 1 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 package dev.msfjarvis.lobsters.ui
import androidx.compose.animation.animate
import androidx.compose.foundation.Text import androidx.compose.foundation.Text
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.lazy.LazyItemScope
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -31,18 +34,27 @@ fun LazyItemScope.LobstersItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
linkOpenAction: (LobstersPost) -> Unit, linkOpenAction: (LobstersPost) -> Unit,
commentOpenAction: (LobstersPost) -> Unit, commentOpenAction: (LobstersPost) -> Unit,
saveAction: (LobstersPost) -> Unit,
) { ) {
val liked = remember { mutableStateOf(false) }
val titleColor = if (post.isLiked || liked.value) savedTitleColor else titleColor
Column( Column(
modifier = modifier modifier = modifier
.fillParentMaxWidth() .fillParentMaxWidth()
.clickable( .clickable(
onClick = { linkOpenAction.invoke(post) }, onClick = { linkOpenAction.invoke(post) },
onLongClick = { commentOpenAction.invoke(post) } onLongClick = { commentOpenAction.invoke(post) },
onDoubleClick = {
post.isLiked = true
liked.value = true
saveAction.invoke(post)
},
), ),
) { ) {
Text( Text(
text = post.title, text = post.title,
color = Color(0xFF7395D9), color = titleColor,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 4.dp) modifier = Modifier.padding(top = 4.dp)
) )
@ -106,11 +118,15 @@ fun PreviewLobstersItem() {
null, null,
null, null,
), ),
listOf("openbsd") listOf("openbsd"),
) )
LobstersTheme { LobstersTheme {
LazyColumnFor(items = listOf(post)) { item -> 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.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val titleColor = Color(0xFF7395D9)
val savedTitleColor = Color(0xFFD97373)
val darkColors = darkColors( val darkColors = darkColors(
primary = Color.White, primary = Color.White,
secondary = Color(0xFF6C0000), secondary = Color(0xFF6C0000),

View file

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

View file

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

View file

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

View file

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