From d6d82248a8ffb6cadd4c487de97b0ac84cb1753b Mon Sep 17 00:00:00 2001 From: Aditya Wasan Date: Sun, 18 Oct 2020 16:03:07 +0530 Subject: [PATCH] Add saved lists feature Signed-off-by: Aditya Wasan --- .../dev/msfjarvis/lobsters/MainActivity.kt | 147 ++++++++++++++---- .../lobsters/data/LobstersViewModel.kt | 31 +++- .../dev/msfjarvis/lobsters/ui/LobstersItem.kt | 24 ++- .../java/dev/msfjarvis/lobsters/ui/Theme.kt | 3 + app/src/main/res/values/strings.xml | 1 + .../lobsters/data/source/PostsDao.kt | 10 ++ .../lobsters/data/source/PostsDatabase.kt | 5 +- .../msfjarvis/lobsters/model/LobstersPost.kt | 3 +- 8 files changed, 185 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/dev/msfjarvis/lobsters/MainActivity.kt b/app/src/main/java/dev/msfjarvis/lobsters/MainActivity.kt index 0e91e2a1..b2c1865e 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/MainActivity.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/MainActivity.kt @@ -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 { - LazyColumnForIndexed( - items = state.value, - modifier = Modifier.padding(horizontal = 8.dp) - ) { index, item -> - if (lastIndex == index) { - viewModel.getMorePosts() - } - 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) + LobsterList( + showSaved, + savedPostsState, + hottestPostsState, + lastIndex, + viewModel, + urlLauncher + ) } }, + floatingActionButton = { LobsterFAB(showSaved, viewModel) }, ) } + +@Composable +private fun LobsterFAB( + showSaved: MutableState, + 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, + savedPostsState: State>, + hottestPostsState: State>, + 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) { + 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) { + 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, + ) + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/lobsters/data/LobstersViewModel.kt b/app/src/main/java/dev/msfjarvis/lobsters/data/LobstersViewModel.kt index b6b60a5c..8eb710a8 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/data/LobstersViewModel.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/data/LobstersViewModel.kt @@ -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>(emptyList()) - private val dao = database.postsDao() + private val _savedPosts = MutableStateFlow>(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> get() = _posts + val savedPosts: StateFlow> 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() } } } diff --git a/app/src/main/java/dev/msfjarvis/lobsters/ui/LobstersItem.kt b/app/src/main/java/dev/msfjarvis/lobsters/ui/LobstersItem.kt index fc61b9d4..543edf94 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/ui/LobstersItem.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/ui/LobstersItem.kt @@ -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 = {}) } } } diff --git a/app/src/main/java/dev/msfjarvis/lobsters/ui/Theme.kt b/app/src/main/java/dev/msfjarvis/lobsters/ui/Theme.kt index c98c31f0..a2b3da07 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/ui/Theme.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/ui/Theme.kt @@ -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), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index df9597fb..3809cb8b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ lobste.rs Loading posts… + You don\'t have any saved posts diff --git a/data/src/main/java/dev/msfjarvis/lobsters/data/source/PostsDao.kt b/data/src/main/java/dev/msfjarvis/lobsters/data/source/PostsDao.kt index dcee27ce..1442710d 100644 --- a/data/src/main/java/dev/msfjarvis/lobsters/data/source/PostsDao.kt +++ b/data/src/main/java/dev/msfjarvis/lobsters/data/source/PostsDao.kt @@ -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> + @Update + suspend fun updatePost(vararg posts: LobstersPost) { + updatePosts(posts.map { LobstersEntity(it) }) + } + + @Update(onConflict = OnConflictStrategy.IGNORE) + protected abstract suspend fun updatePosts(posts: List) + + @Transaction open suspend fun insertPosts(vararg posts: LobstersPost) { insertPosts(posts.map { LobstersEntity(it) }) diff --git a/data/src/main/java/dev/msfjarvis/lobsters/data/source/PostsDatabase.kt b/data/src/main/java/dev/msfjarvis/lobsters/data/source/PostsDatabase.kt index baaaeab1..d1e8a36e 100644 --- a/data/src/main/java/dev/msfjarvis/lobsters/data/source/PostsDatabase.kt +++ b/data/src/main/java/dev/msfjarvis/lobsters/data/source/PostsDatabase.kt @@ -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 } diff --git a/model/src/main/java/dev/msfjarvis/lobsters/model/LobstersPost.kt b/model/src/main/java/dev/msfjarvis/lobsters/model/LobstersPost.kt index ed0496c0..5feb2f92 100644 --- a/model/src/main/java/dev/msfjarvis/lobsters/model/LobstersPost.kt +++ b/model/src/main/java/dev/msfjarvis/lobsters/model/LobstersPost.kt @@ -22,5 +22,6 @@ class LobstersPost( val commentsUrl: String, @Json(name = "submitter_user") val submitterUser: Submitter, - val tags: List + val tags: List, + var isLiked: Boolean = false, )