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.appcompat.app.AppCompatActivity
import androidx.compose.foundation.Text import androidx.compose.foundation.Text
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumnForIndexed import androidx.compose.foundation.lazy.LazyColumnForIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.FloatingActionButton import androidx.compose.material.FloatingActionButton
import androidx.compose.material.IconToggleButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold import androidx.compose.material.Scaffold
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Providers import androidx.compose.runtime.Providers
import androidx.compose.runtime.ambientOf import androidx.compose.runtime.ambientOf
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.setContent import androidx.compose.ui.platform.setContent
@ -23,8 +31,10 @@ import androidx.compose.ui.unit.dp
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.msfjarvis.lobsters.compose.utils.IconResource import dev.msfjarvis.lobsters.compose.utils.IconResource
import dev.msfjarvis.lobsters.data.LobstersViewModel import dev.msfjarvis.lobsters.data.LobstersViewModel
import dev.msfjarvis.lobsters.model.LobstersPost
import dev.msfjarvis.lobsters.ui.LobstersItem import dev.msfjarvis.lobsters.ui.LobstersItem
import dev.msfjarvis.lobsters.ui.LobstersTheme import dev.msfjarvis.lobsters.ui.LobstersTheme
import dev.msfjarvis.lobsters.ui.savedTitleColor
import dev.msfjarvis.lobsters.urllauncher.UrlLauncher import dev.msfjarvis.lobsters.urllauncher.UrlLauncher
import javax.inject.Inject import javax.inject.Inject
@ -52,40 +62,128 @@ fun LobstersApp(
viewModel: LobstersViewModel viewModel: LobstersViewModel
) { ) {
val urlLauncher = UrlLauncherAmbient.current val urlLauncher = UrlLauncherAmbient.current
val state = viewModel.posts.collectAsState() val posts = viewModel.posts.collectAsState()
val lastIndex = state.value.lastIndex val savedPosts = viewModel.savedPosts.collectAsState()
val lastIndex = posts.value.lastIndex
val showSaved = remember { mutableStateOf(false) }
Scaffold( Scaffold(
topBar = {
LobstersTopAppBar(showSaved.value) {
showSaved.value = !showSaved.value
}
},
bodyContent = { bodyContent = {
if (state.value.isEmpty()) { val saved = showSaved.value
Column( if (saved && savedPosts.value.isEmpty()) {
modifier = Modifier.fillMaxSize(), EmptyList(saved)
verticalArrangement = Arrangement.Center, } else if (!saved && posts.value.isEmpty()) {
horizontalAlignment = Alignment.CenterHorizontally, EmptyList(saved)
) {
IconResource(R.drawable.ic_sync_problem_24px)
Text(stringResource(R.string.loading))
}
} else { } else {
LazyColumnForIndexed( LobsterList(
items = state.value, showSaved.value,
modifier = Modifier.padding(horizontal = 8.dp) savedPosts.value,
) { index, item -> posts.value,
if (lastIndex == index) { lastIndex,
viewModel.getMorePosts() viewModel,
} urlLauncher
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)
} }
}, },
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 package dev.msfjarvis.lobsters.compose.utils
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.compose.foundation.Icon
import androidx.compose.foundation.AmbientContentColor import androidx.compose.foundation.AmbientContentColor
import androidx.compose.foundation.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color

View file

@ -20,14 +20,16 @@ class LobstersViewModel @ViewModelInject constructor(
) : ViewModel() { ) : ViewModel() {
private var apiPage = 1 private var apiPage = 1
private val _posts = MutableStateFlow<List<LobstersPost>>(emptyList()) 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 -> private val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
when (throwable) { when (throwable) {
// Swallow known network errors that can be recovered from. // Swallow known network errors that can be recovered from.
is UnknownHostException, is SocketTimeoutException -> { is UnknownHostException, is SocketTimeoutException -> {
if (_posts.value.isEmpty()) { if (_posts.value.isEmpty()) {
viewModelScope.launch { 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 posts: StateFlow<List<LobstersPost>> get() = _posts
val savedPosts: StateFlow<List<LobstersPost>> get() = _savedPosts
init { init {
getMorePostsInternal(true) getMorePostsInternal(true)
getSavedPosts()
}
private fun getSavedPosts() {
viewModelScope.launch {
savedPostsDao.loadPosts().collectLatest { _savedPosts.value = it }
}
} }
fun getMorePosts() { fun getMorePosts() {
@ -52,14 +62,36 @@ class LobstersViewModel @ViewModelInject constructor(
private fun getMorePostsInternal(firstLoad: Boolean) { private fun getMorePostsInternal(firstLoad: Boolean) {
viewModelScope.launch(coroutineExceptionHandler) { viewModelScope.launch(coroutineExceptionHandler) {
val newPosts = lobstersApi.getHottestPosts(apiPage) val newPosts = lobstersApi.getHottestPosts(apiPage)
.transformLikedFlag()
.toList()
if (firstLoad) { if (firstLoad) {
_posts.value = newPosts _posts.value = newPosts
dao.deleteAllPosts() postsDao.deleteAllPosts()
} else { } else {
_posts.value += newPosts _posts.value += newPosts
} }
apiPage += 1 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 @Module
object ApiModule { object ApiModule {
const val LOBSTERS_URL = "https://lobste.rs" const val LOBSTERS_URL = "https://lobste.rs"
@Provides @Provides
fun provideLobstersApi(): LobstersApi { fun provideLobstersApi(): LobstersApi {
return ApiClient.getClient(LOBSTERS_URL) 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.lazy.LazyItemScope
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -31,18 +33,27 @@ fun LazyItemScope.LobstersItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
linkOpenAction: (LobstersPost) -> Unit, linkOpenAction: (LobstersPost) -> Unit,
commentOpenAction: (LobstersPost) -> Unit, commentOpenAction: (LobstersPost) -> Unit,
saveAction: (LobstersPost) -> Unit,
) { ) {
val liked = remember { mutableStateOf(false) }
val titleColor = if (post.isLiked || liked.value) savedTitleColor else titleColor
Column( Column(
modifier = modifier modifier = modifier
.fillParentMaxWidth() .fillParentMaxWidth()
.clickable( .clickable(
onClick = { linkOpenAction.invoke(post) }, onClick = { linkOpenAction.invoke(post) },
onLongClick = { commentOpenAction.invoke(post) } onLongClick = { commentOpenAction.invoke(post) },
onDoubleClick = {
post.isLiked = true
liked.value = true
saveAction.invoke(post)
},
), ),
) { ) {
Text( Text(
text = post.title, text = post.title,
color = Color(0xFF7395D9), color = titleColor,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 4.dp) modifier = Modifier.padding(top = 4.dp)
) )
@ -106,11 +117,15 @@ fun PreviewLobstersItem() {
null, null,
null, null,
), ),
listOf("openbsd") listOf("openbsd"),
) )
LobstersTheme { LobstersTheme {
LazyColumnFor(items = listOf(post)) { item -> 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.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val titleColor = Color(0xFF7395D9)
val savedTitleColor = Color(0xFFD97373)
val darkColors = darkColors( val darkColors = darkColors(
primary = Color.White, primary = Color.White,
secondary = Color(0xFF6C0000), 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> <resources>
<string name="app_name">lobste.rs</string> <string name="app_name">lobste.rs</string>
<string name="loading">Loading posts…</string> <string name="loading">Loading posts…</string>
<string name="no_saved_posts">You don\'t have any saved posts</string>
</resources> </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.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.Update
import dev.msfjarvis.lobsters.data.model.LobstersEntity import dev.msfjarvis.lobsters.data.model.LobstersEntity
import dev.msfjarvis.lobsters.model.LobstersPost import dev.msfjarvis.lobsters.model.LobstersPost
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -15,6 +16,15 @@ abstract class PostsDao {
@Query("SELECT * FROM lobsters_posts") @Query("SELECT * FROM lobsters_posts")
abstract fun loadPosts(): Flow<List<LobstersPost>> 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 @Transaction
open suspend fun insertPosts(vararg posts: LobstersPost) { open suspend fun insertPosts(vararg posts: LobstersPost) {
insertPosts(posts.map { LobstersEntity(it) }) insertPosts(posts.map { LobstersEntity(it) })

View file

@ -4,12 +4,14 @@ import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import dev.msfjarvis.lobsters.data.model.LobstersEntity import dev.msfjarvis.lobsters.data.model.LobstersEntity
import dev.msfjarvis.lobsters.data.model.SavedLobstersEntity
@Database( @Database(
entities = [ entities = [
LobstersEntity::class, LobstersEntity::class,
SavedLobstersEntity::class
], ],
version = 1, version = 2,
exportSchema = false, exportSchema = false,
) )
@TypeConverters( @TypeConverters(
@ -17,4 +19,5 @@ import dev.msfjarvis.lobsters.data.model.LobstersEntity
) )
abstract class PostsDatabase : RoomDatabase() { abstract class PostsDatabase : RoomDatabase() {
abstract fun postsDao(): PostsDao 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 import java.io.File
object TestUtils { object TestUtils {
fun getJson(path : String) : String { fun getJson(path: String): String {
// Load the JSON response // Load the JSON response
val uri = javaClass.classLoader.getResource(path) val uri = javaClass.classLoader.getResource(path)
val file = File(uri.path) val file = File(uri.path)

View file

@ -22,5 +22,6 @@ class LobstersPost(
val commentsUrl: String, val commentsUrl: String,
@Json(name = "submitter_user") @Json(name = "submitter_user")
val submitterUser: Submitter, val submitterUser: Submitter,
val tags: List<String> val tags: List<String>,
var isLiked: Boolean = false,
) )