mirror of
https://github.com/msfjarvis/compose-lobsters
synced 2025-08-17 22:37:03 +05:30
Merge pull request #43 from Skrilltrax/develop
This commit is contained in:
commit
85a5c829e0
15 changed files with 279 additions and 41 deletions
|
@ -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<LobstersPost>,
|
||||
hottestPosts: List<LobstersPost>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -20,14 +20,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 +37,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() {
|
||||
|
@ -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<LobstersPost>.transformLikedFlag() = map {
|
||||
it.apply { isLiked = savedPostsDao.isLiked(shortId) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
9
app/src/main/res/drawable/ic_favorite_24px.xml
Normal file
9
app/src/main/res/drawable/ic_favorite_24px.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
|
||||
</vector>
|
9
app/src/main/res/drawable/ic_favorite_border_24px.xml
Normal file
9
app/src/main/res/drawable/ic_favorite_border_24px.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z"/>
|
||||
</vector>
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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<List<LobstersPost>>
|
||||
|
||||
@Transaction
|
||||
open suspend fun insertPosts(vararg posts: LobstersPost) {
|
||||
insertPosts(posts.map { SavedLobstersEntity(it) })
|
||||
}
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
protected abstract suspend fun insertPosts(posts: List<SavedLobstersEntity>)
|
||||
|
||||
@Transaction
|
||||
open suspend fun deletePosts(vararg posts: LobstersPost) {
|
||||
deletePosts(posts.map { SavedLobstersEntity(it) })
|
||||
}
|
||||
|
||||
@Delete
|
||||
protected abstract suspend fun deletePosts(posts: List<SavedLobstersEntity>)
|
||||
|
||||
@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
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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