Merge pull request #43 from Skrilltrax/develop

This commit is contained in:
Harsh Shandilya 2020-10-19 00:31:11 +05:30 committed by GitHub
commit 85a5c829e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 279 additions and 41 deletions

View file

@ -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,
)
}
}
}
}

View file

@ -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

View file

@ -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) }
}
}

View file

@ -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)

View file

@ -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 = {})
}
}
}

View file

@ -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),

View 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>

View 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>

View file

@ -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>

View file

@ -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
)

View file

@ -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) })

View file

@ -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
}

View file

@ -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
}

View file

@ -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)

View file

@ -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,
)