diff --git a/app/src/main/java/dev/msfjarvis/lobsters/MainActivity.kt b/app/src/main/java/dev/msfjarvis/lobsters/MainActivity.kt index 0e91e2a1..fedaf6be 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/MainActivity.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/MainActivity.kt @@ -5,16 +5,24 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity 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.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.runtime.Composable import androidx.compose.runtime.Providers 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 +31,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 +62,128 @@ fun LobstersApp( viewModel: LobstersViewModel ) { val urlLauncher = UrlLauncherAmbient.current - val state = viewModel.posts.collectAsState() - val lastIndex = state.value.lastIndex + val posts = viewModel.posts.collectAsState() + val savedPosts = viewModel.savedPosts.collectAsState() + val lastIndex = posts.value.lastIndex + val showSaved = remember { mutableStateOf(false) } Scaffold( + topBar = { + LobstersTopAppBar(showSaved.value) { + showSaved.value = !showSaved.value + } + }, 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)) - } + val saved = showSaved.value + if (saved && savedPosts.value.isEmpty()) { + EmptyList(saved) + } else if (!saved && posts.value.isEmpty()) { + EmptyList(saved) } 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.value, + savedPosts.value, + posts.value, + lastIndex, + viewModel, + urlLauncher + ) } }, + floatingActionButton = { LobstersFAB(showSaved.value, viewModel) }, ) } + +@Composable +private fun LobstersFAB( + showSaved: Boolean, + viewModel: LobstersViewModel +) { + if (!showSaved) { + FloatingActionButton( + onClick = { viewModel.refreshPosts() }, + modifier = Modifier + ) { + IconResource(resourceId = R.drawable.ic_refresh_24px) + } + } +} + +@Composable +private fun LobsterList( + showSaved: Boolean, + savedPosts: List, + hottestPosts: List, + lastIndex: Int, + viewModel: LobstersViewModel, + urlLauncher: UrlLauncher +) { + val hottestPostsListState = rememberLazyListState() + val savedPostsListState = rememberLazyListState() + + LazyColumnForIndexed( + items = if (showSaved) savedPosts else hottestPosts, + state = if (showSaved) savedPostsListState else hottestPostsListState, + modifier = Modifier.padding(horizontal = 8.dp) + ) { index, item -> + if (lastIndex == index && !showSaved) { + viewModel.getMorePosts() + } + LobstersItem( + item, + linkOpenAction = { post -> urlLauncher.launch(post.url.ifEmpty { post.commentsUrl }) }, + commentOpenAction = { post -> urlLauncher.launch(post.commentsUrl) }, + saveAction = { post -> + if (showSaved) { + viewModel.removeSavedPost(post) + } else { + viewModel.savePost(post) + } + }, + ) + } +} + +@Composable +private fun EmptyList(showSaved: Boolean) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (showSaved) { + IconResource( + R.drawable.ic_favorite_border_24px, + tint = savedTitleColor, + modifier = Modifier.padding(16.dp) + ) + Text(stringResource(R.string.no_saved_posts)) + } else { + IconResource(R.drawable.ic_sync_problem_24px, modifier = Modifier.padding(16.dp)) + Text(stringResource(R.string.loading)) + } + } +} + +@Composable +private fun LobstersTopAppBar(showSaved: Boolean, toggleAction: () -> Unit) { + TopAppBar { + Box(modifier = Modifier.fillMaxWidth()) { + Text( + text = if (showSaved) "Saved" else "Home", + modifier = Modifier.padding(16.dp).align(Alignment.CenterStart), + style = MaterialTheme.typography.h6, + ) + IconToggleButton( + checked = showSaved, + onCheckedChange = { toggleAction.invoke() }, + modifier = Modifier.padding(8.dp).align(Alignment.CenterEnd), + ) { + IconResource( + resourceId = if (showSaved) R.drawable.ic_favorite_24px else R.drawable.ic_favorite_border_24px, + tint = savedTitleColor, + ) + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/lobsters/compose/utils/DeferredIcon.kt b/app/src/main/java/dev/msfjarvis/lobsters/compose/utils/DeferredIcon.kt index c9301983..35116fc6 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/compose/utils/DeferredIcon.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/compose/utils/DeferredIcon.kt @@ -16,8 +16,8 @@ package dev.msfjarvis.lobsters.compose.utils import androidx.annotation.DrawableRes -import androidx.compose.foundation.Icon import androidx.compose.foundation.AmbientContentColor +import androidx.compose.foundation.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color 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..a01781f3 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/data/LobstersViewModel.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/data/LobstersViewModel.kt @@ -20,14 +20,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 +37,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() { @@ -52,14 +62,36 @@ class LobstersViewModel @ViewModelInject constructor( private fun getMorePostsInternal(firstLoad: Boolean) { viewModelScope.launch(coroutineExceptionHandler) { val newPosts = lobstersApi.getHottestPosts(apiPage) + .transformLikedFlag() + .toList() 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 { + savedPostsDao.insertPosts(post) + getSavedPosts() + _posts.value = _posts.value.transformLikedFlag().toList() + } + } + + fun removeSavedPost(post: LobstersPost) { + viewModelScope.launch { + savedPostsDao.deletePostById(post.shortId) + getSavedPosts() + _posts.value = _posts.value.transformLikedFlag().toList() + } + } + + private suspend fun List.transformLikedFlag() = map { + it.apply { isLiked = savedPostsDao.isLiked(shortId) } + } } diff --git a/app/src/main/java/dev/msfjarvis/lobsters/di/ApiModule.kt b/app/src/main/java/dev/msfjarvis/lobsters/di/ApiModule.kt index dfa1042a..f7f970f4 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/di/ApiModule.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/di/ApiModule.kt @@ -11,6 +11,7 @@ import dev.msfjarvis.lobsters.api.LobstersApi @Module object ApiModule { const val LOBSTERS_URL = "https://lobste.rs" + @Provides fun provideLobstersApi(): LobstersApi { return ApiClient.getClient(LOBSTERS_URL) 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..7d831829 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/ui/LobstersItem.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/ui/LobstersItem.kt @@ -13,6 +13,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 +33,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 +117,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/drawable/ic_favorite_24px.xml b/app/src/main/res/drawable/ic_favorite_24px.xml new file mode 100644 index 00000000..ce351f43 --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_favorite_border_24px.xml b/app/src/main/res/drawable/ic_favorite_border_24px.xml new file mode 100644 index 00000000..e6646709 --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_border_24px.xml @@ -0,0 +1,9 @@ + + + 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/model/SavedLobstersEntity.kt b/data/src/main/java/dev/msfjarvis/lobsters/data/model/SavedLobstersEntity.kt new file mode 100644 index 00000000..909cb6f3 --- /dev/null +++ b/data/src/main/java/dev/msfjarvis/lobsters/data/model/SavedLobstersEntity.kt @@ -0,0 +1,14 @@ +package dev.msfjarvis.lobsters.data.model + +import androidx.room.Embedded +import androidx.room.Entity +import dev.msfjarvis.lobsters.model.LobstersPost + +@Entity( + tableName = "lobsters_saved_posts", + primaryKeys = ["shortId"], +) +data class SavedLobstersEntity( + @Embedded + val post: LobstersPost +) 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/data/src/main/java/dev/msfjarvis/lobsters/data/source/SavedPostsDao.kt b/data/src/main/java/dev/msfjarvis/lobsters/data/source/SavedPostsDao.kt new file mode 100644 index 00000000..bb51bbb6 --- /dev/null +++ b/data/src/main/java/dev/msfjarvis/lobsters/data/source/SavedPostsDao.kt @@ -0,0 +1,42 @@ +package dev.msfjarvis.lobsters.data.source + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import dev.msfjarvis.lobsters.data.model.SavedLobstersEntity +import dev.msfjarvis.lobsters.model.LobstersPost +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class SavedPostsDao { + @Query("SELECT * FROM lobsters_saved_posts") + abstract fun loadPosts(): Flow> + + @Transaction + open suspend fun insertPosts(vararg posts: LobstersPost) { + insertPosts(posts.map { SavedLobstersEntity(it) }) + } + + @Insert(onConflict = OnConflictStrategy.IGNORE) + protected abstract suspend fun insertPosts(posts: List) + + @Transaction + open suspend fun deletePosts(vararg posts: LobstersPost) { + deletePosts(posts.map { SavedLobstersEntity(it) }) + } + + @Delete + protected abstract suspend fun deletePosts(posts: List) + + @Query("DELETE FROM lobsters_saved_posts") + abstract suspend fun deleteAllPosts() + + @Query("DELETE FROM lobsters_saved_posts WHERE shortId LIKE :shortId") + abstract suspend fun deletePostById(shortId: String) + + @Query("SELECT EXISTS(SELECT 1 FROM lobsters_saved_posts WHERE shortId LIKE :shortId)") + abstract suspend fun isLiked(shortId: String): Boolean +} diff --git a/lobsters-api/src/test/java/dev/msfjarvis/lobsters/api/TestUtils.kt b/lobsters-api/src/test/java/dev/msfjarvis/lobsters/api/TestUtils.kt index 04e358b7..7c856a9b 100644 --- a/lobsters-api/src/test/java/dev/msfjarvis/lobsters/api/TestUtils.kt +++ b/lobsters-api/src/test/java/dev/msfjarvis/lobsters/api/TestUtils.kt @@ -3,7 +3,7 @@ package dev.msfjarvis.lobsters.api import java.io.File object TestUtils { - fun getJson(path : String) : String { + fun getJson(path: String): String { // Load the JSON response val uri = javaClass.classLoader.getResource(path) val file = File(uri.path) 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, )