mirror of
https://github.com/msfjarvis/compose-lobsters
synced 2025-08-18 07:57:03 +05:30
Add saved lists feature
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
This commit is contained in:
parent
3bd3f2dff8
commit
d6d82248a8
8 changed files with 185 additions and 39 deletions
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) })
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue