From ebdde81d2e6c036a54ced00c3b9adeee800f46c8 Mon Sep 17 00:00:00 2001 From: longnghia <41385034+longnghia@users.noreply.github.com> Date: Tue, 3 Dec 2024 11:18:19 +0700 Subject: [PATCH 1/6] Add Room database and Favorite page --- app/build.gradle.kts | 5 ++ .../hviewer/database/AppDatabase.kt | 10 +++ .../hviewer/database/DatabaseProvider.kt | 18 ++++++ .../hviewer/database/FavoriteDao.kt | 21 +++++++ .../com/paulcoding/hviewer/model/PostModel.kt | 7 ++- .../paulcoding/hviewer/ui/component/HIcon.kt | 14 ++++- .../hviewer/ui/favorite/FavoritePage.kt | 63 +++++++++++++++++++ .../paulcoding/hviewer/ui/page/AppEntry.kt | 13 ++++ .../hviewer/ui/page/AppViewModel.kt | 17 +++++ .../com/paulcoding/hviewer/ui/page/Route.kt | 1 + .../hviewer/ui/page/posts/PostsPage.kt | 49 +++++++++++---- .../hviewer/ui/page/sites/SitesPage.kt | 5 ++ build.gradle.kts | 1 + gradle/libs.versions.toml | 4 ++ 14 files changed, 215 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/paulcoding/hviewer/database/AppDatabase.kt create mode 100644 app/src/main/java/com/paulcoding/hviewer/database/DatabaseProvider.kt create mode 100644 app/src/main/java/com/paulcoding/hviewer/database/FavoriteDao.kt create mode 100644 app/src/main/java/com/paulcoding/hviewer/ui/favorite/FavoritePage.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 07899d4..b101d1e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + id("com.google.devtools.ksp") } android { @@ -81,6 +82,10 @@ dependencies { implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.logging) + implementation(libs.androidx.room.runtime) + ksp(libs.androidx.room.compiler) + implementation(libs.androidx.room.ktx) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/com/paulcoding/hviewer/database/AppDatabase.kt b/app/src/main/java/com/paulcoding/hviewer/database/AppDatabase.kt new file mode 100644 index 0000000..b25dcf3 --- /dev/null +++ b/app/src/main/java/com/paulcoding/hviewer/database/AppDatabase.kt @@ -0,0 +1,10 @@ +package com.paulcoding.hviewer.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.paulcoding.hviewer.model.PostItem + +@Database(entities = [PostItem::class], version = 1) +abstract class AppDatabase : RoomDatabase() { + abstract fun favoritePostDao(): FavoritePostDao +} \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/database/DatabaseProvider.kt b/app/src/main/java/com/paulcoding/hviewer/database/DatabaseProvider.kt new file mode 100644 index 0000000..9d87064 --- /dev/null +++ b/app/src/main/java/com/paulcoding/hviewer/database/DatabaseProvider.kt @@ -0,0 +1,18 @@ +package com.paulcoding.hviewer.database + +import androidx.room.Room +import com.paulcoding.hviewer.MainApp.Companion.appContext + +object DatabaseProvider { + private var db: AppDatabase? = null + + fun getInstance(): AppDatabase { + if (db == null) { + db = Room.databaseBuilder( + appContext, + AppDatabase::class.java, "hviewer_db" + ).build() + } + return db!! + } +} \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/database/FavoriteDao.kt b/app/src/main/java/com/paulcoding/hviewer/database/FavoriteDao.kt new file mode 100644 index 0000000..2cf0e84 --- /dev/null +++ b/app/src/main/java/com/paulcoding/hviewer/database/FavoriteDao.kt @@ -0,0 +1,21 @@ +package com.paulcoding.hviewer.database + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.paulcoding.hviewer.model.PostItem +import kotlinx.coroutines.flow.Flow + +@Dao +interface FavoritePostDao { + @Query("SELECT * FROM favorite_posts") + fun getAll(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(post: PostItem) + + @Delete + suspend fun delete(post: PostItem) +} \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/model/PostModel.kt b/app/src/main/java/com/paulcoding/hviewer/model/PostModel.kt index 9d85dee..7219f6e 100644 --- a/app/src/main/java/com/paulcoding/hviewer/model/PostModel.kt +++ b/app/src/main/java/com/paulcoding/hviewer/model/PostModel.kt @@ -1,5 +1,8 @@ package com.paulcoding.hviewer.model + import androidx.room.Entity +import androidx.room.PrimaryKey + data class PostData( val images: List, val total: Int, @@ -7,9 +10,11 @@ data class PostData( ) +@Entity(tableName = "favorite_posts") data class PostItem( - val name: String = "", + @PrimaryKey val url: String = "", + val name: String = "", val thumbnail: String = "", ) diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/component/HIcon.kt b/app/src/main/java/com/paulcoding/hviewer/ui/component/HIcon.kt index 1d7acbd..c8f3902 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/component/HIcon.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/component/HIcon.kt @@ -2,10 +2,14 @@ package com.paulcoding.hviewer.ui.component import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import com.paulcoding.hviewer.ui.icon.SettingsIcon @@ -16,14 +20,22 @@ fun HBackIcon(onClick: () -> Unit) { } } +@Composable +fun HFavoriteIcon(modifier: Modifier = Modifier, isFavorite: Boolean, onClick: () -> Unit) { + val icon = if (isFavorite) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder + val tint: Color = if (isFavorite) Color.Red else Color.Gray + HIcon(modifier = modifier, imageVector = icon, tint = tint, onClick = onClick) +} + @Composable fun HIcon( modifier: Modifier = Modifier, imageVector: ImageVector = SettingsIcon, description: String = "", + tint: Color = LocalContentColor.current, onClick: () -> Unit ) { IconButton(onClick = { onClick() }, modifier = modifier) { - Icon(imageVector, description) + Icon(imageVector, description, tint = tint) } } \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/favorite/FavoritePage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/favorite/FavoritePage.kt new file mode 100644 index 0000000..a9efdd2 --- /dev/null +++ b/app/src/main/java/com/paulcoding/hviewer/ui/favorite/FavoritePage.kt @@ -0,0 +1,63 @@ +package com.paulcoding.hviewer.ui.favorite + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import com.paulcoding.hviewer.model.PostItem +import com.paulcoding.hviewer.ui.component.HBackIcon +import com.paulcoding.hviewer.ui.component.HEmpty +import com.paulcoding.hviewer.ui.page.AppViewModel +import com.paulcoding.hviewer.ui.page.posts.PostCard + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FavoritePage( + appViewModel: AppViewModel, + navToImages: (PostItem) -> Unit, + goBack: () -> Boolean +) { + val viewModel: AppViewModel = viewModel() + val favoritePosts by viewModel.favoritePosts.collectAsState(initial = emptyList()) + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar(title = { Text("Favorite") }, navigationIcon = { + HBackIcon { goBack() } + }) + }) { paddings -> + LazyColumn(modifier = Modifier.padding(paddings)) { + items(items = favoritePosts.reversed(), key = { it.url }) { item -> + FavoriteItem(item, navToImages = { navToImages(it) }, deleteFavorite = { + appViewModel.deleteFavorite(it) + }) + } + if (favoritePosts.isEmpty()) + item { HEmpty() } + } + } + +} + +@Composable +fun FavoriteItem( + post: PostItem, + navToImages: (PostItem) -> Unit, + deleteFavorite: (PostItem) -> Unit +) { + PostCard(post, isFavorite = true, setFavorite = { + deleteFavorite(post) + }) { + navToImages(post) + } +} diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/AppEntry.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppEntry.kt index e1efc55..23fc26f 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/AppEntry.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppEntry.kt @@ -20,6 +20,7 @@ import androidx.navigation.compose.rememberNavController import com.paulcoding.hviewer.model.PostItem import com.paulcoding.hviewer.model.SiteConfigs import com.paulcoding.hviewer.network.Github +import com.paulcoding.hviewer.ui.favorite.FavoritePage import com.paulcoding.hviewer.ui.page.post.PostPage import com.paulcoding.hviewer.ui.page.posts.PostsPage import com.paulcoding.hviewer.ui.page.search.SearchPage @@ -49,6 +50,9 @@ fun AppEntry() { }, navToSettings = { navController.navigate(Route.SETTINGS) }, + navToFavorite = { + navController.navigate(Route.FAVORITE) + }, goBack = { navController.popBackStack() }) } animatedComposable(Route.SETTINGS) { @@ -80,6 +84,15 @@ fun AppEntry() { goBack = { navController.popBackStack() }, ) } + animatedComposable(Route.FAVORITE) { + FavoritePage( + appViewModel = appViewModel, + navToImages = { post: PostItem -> + navToImages(post) + }, + goBack = { navController.popBackStack() } + ) + } } } diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt index 60b2935..2ea2d6b 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt @@ -1,16 +1,21 @@ package com.paulcoding.hviewer.ui.page import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.paulcoding.hviewer.database.DatabaseProvider import com.paulcoding.hviewer.model.PostItem import com.paulcoding.hviewer.model.SiteConfig import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch class AppViewModel : ViewModel() { private var _stateFlow = MutableStateFlow(UiState()) val stateFlow = _stateFlow.asStateFlow() + val favoritePosts = DatabaseProvider.getInstance().favoritePostDao().getAll() + fun setCurrentPost(post: PostItem) { _stateFlow.update { it.copy(post = post) } } @@ -23,4 +28,16 @@ class AppViewModel : ViewModel() { val post: PostItem = PostItem(), val siteConfig: SiteConfig = SiteConfig(), ) + + fun addFavorite(postItem: PostItem) { + viewModelScope.launch { + DatabaseProvider.getInstance().favoritePostDao().insert(postItem) + } + } + + fun deleteFavorite(postItem: PostItem) { + viewModelScope.launch { + DatabaseProvider.getInstance().favoritePostDao().delete(postItem) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/Route.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/Route.kt index cd1c5e7..6862616 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/Route.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/Route.kt @@ -8,4 +8,5 @@ object Route { const val POST = "post" const val SEARCH = "search" const val SETTINGS = "settings" + const val FAVORITE = "favorite" } \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsPage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsPage.kt index fe28733..d16f8de 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsPage.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsPage.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -40,6 +41,7 @@ import com.paulcoding.hviewer.model.PostItem import com.paulcoding.hviewer.model.SiteConfig import com.paulcoding.hviewer.ui.component.HBackIcon import com.paulcoding.hviewer.ui.component.HEmpty +import com.paulcoding.hviewer.ui.component.HFavoriteIcon import com.paulcoding.hviewer.ui.component.HGoTop import com.paulcoding.hviewer.ui.component.HIcon import com.paulcoding.hviewer.ui.component.HImage @@ -103,6 +105,7 @@ fun PostsPage( ) { pageIndex -> val page = listTopic[pageIndex] PageContent( + appViewModel, siteConfig, page, onPageChange = { currentPage, total -> @@ -117,11 +120,13 @@ fun PostsPage( @Composable fun PageContent( + appViewModel: AppViewModel, siteConfig: SiteConfig, topic: String, onPageChange: (Int, Int) -> Unit, onClick: (PostItem) -> Unit ) { + val listFavorite by appViewModel.favoritePosts.collectAsState(initial = emptyList()) val viewModel: PostsViewModel = viewModel( factory = PostsViewModelFactory(siteConfig, topic), key = topic @@ -154,7 +159,16 @@ fun PageContent( state = listState ) { items(uiState.postItems) { post -> - PostCard(post) { + PostCard( + post, + isFavorite = listFavorite.find { it.url == post.url } != null, + setFavorite = { isFavorite -> + if (isFavorite) + appViewModel.addFavorite(post) + else + appViewModel.deleteFavorite(post) + } + ) { onClick(post) } } @@ -177,22 +191,35 @@ fun PageContent( } @Composable -fun PostCard(postItem: PostItem, viewPost: () -> Unit) { +fun PostCard( + postItem: PostItem, + isFavorite: Boolean = false, + setFavorite: (Boolean) -> Unit = {}, + viewPost: () -> Unit +) { Card( elevation = CardDefaults.cardElevation(8.dp), modifier = Modifier .padding(horizontal = 16.dp, vertical = 12.dp), shape = MaterialTheme.shapes.medium, ) { - Column(modifier = Modifier - .padding(8.dp) - .clickable { - viewPost() - }) { - HImage( - url = postItem.thumbnail - ) - Text(postItem.name, fontSize = 12.sp) + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier + .padding(8.dp) + .clickable { + viewPost() + }) { + HImage( + url = postItem.thumbnail + ) + Text(postItem.name, fontSize = 12.sp) + } + HFavoriteIcon( + modifier = Modifier.align(Alignment.TopEnd), + isFavorite = isFavorite + ) { + setFavorite(!isFavorite) + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/sites/SitesPage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/sites/SitesPage.kt index e6613ca..c195b8f 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/sites/SitesPage.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/sites/SitesPage.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp import com.paulcoding.hviewer.model.SiteConfig import com.paulcoding.hviewer.model.SiteConfigs import com.paulcoding.hviewer.ui.component.HEmpty +import com.paulcoding.hviewer.ui.component.HFavoriteIcon import com.paulcoding.hviewer.ui.icon.SettingsIcon import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -42,6 +43,7 @@ fun SitesPage( siteConfigs: SiteConfigs, navToSettings: () -> Unit, refresh: () -> Unit, + navToFavorite: () -> Unit, ) { val state = rememberPullToRefreshState() @@ -53,6 +55,9 @@ fun SitesPage( Scaffold(topBar = { TopAppBar(title = { Text("Sites") }, actions = { + HFavoriteIcon(isFavorite = false) { + navToFavorite() + } IconButton(onClick = navToSettings) { Icon(SettingsIcon, "Settings") } diff --git a/build.gradle.kts b/build.gradle.kts index 952b930..b162aa1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 36bbdf8..2bf1b1e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ lottieCompose = "6.6.0" mmkv = "2.0.0" navigationCompose = "2.8.4" rhino = "1.7.15" +roomRuntime = "2.6.1" zoomable = "0.14.0" material3 = "1.3.1" @@ -35,6 +36,9 @@ androidx-concurrent-futures-ktx = { module = "androidx.concurrent:concurrent-fut androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-javascriptengine = { module = "androidx.javascriptengine:javascriptengine", version.ref = "javascriptengine" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomRuntime" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomRuntime" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilNetworkOkhttp" } coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coilNetworkOkhttp" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" } From ac63b954cd6237be24ce906bfed75aa49ef30b34 Mon Sep 17 00:00:00 2001 From: longnghia <41385034+longnghia@users.noreply.github.com> Date: Tue, 3 Dec 2024 14:35:37 +0700 Subject: [PATCH 2/6] Add site field in favorite item to get siteConfig --- app/build.gradle.kts | 4 ++ .../1.json | 52 +++++++++++++++++++ .../2.json | 52 +++++++++++++++++++ .../hviewer/database/AppDatabase.kt | 2 +- .../hviewer/database/DatabaseProvider.kt | 4 +- .../paulcoding/hviewer/database/Migrations.kt | 10 ++++ .../com/paulcoding/hviewer/model/PostModel.kt | 1 + .../paulcoding/hviewer/ui/page/AppEntry.kt | 3 +- .../hviewer/ui/page/AppViewModel.kt | 8 +-- 9 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 app/schemas/com.paulcoding.hviewer.database.AppDatabase/1.json create mode 100644 app/schemas/com.paulcoding.hviewer.database.AppDatabase/2.json create mode 100644 app/src/main/java/com/paulcoding/hviewer/database/Migrations.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b101d1e..c4c50ed 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,4 +93,8 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) +} + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") } \ No newline at end of file diff --git a/app/schemas/com.paulcoding.hviewer.database.AppDatabase/1.json b/app/schemas/com.paulcoding.hviewer.database.AppDatabase/1.json new file mode 100644 index 0000000..fe27ccc --- /dev/null +++ b/app/schemas/com.paulcoding.hviewer.database.AppDatabase/1.json @@ -0,0 +1,52 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "f8c3310b39b34e0c6c9c004f8557d416", + "entities": [ + { + "tableName": "favorite_posts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnail` TEXT NOT NULL, `site` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "site", + "columnName": "site", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "url" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f8c3310b39b34e0c6c9c004f8557d416')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.paulcoding.hviewer.database.AppDatabase/2.json b/app/schemas/com.paulcoding.hviewer.database.AppDatabase/2.json new file mode 100644 index 0000000..013c5d0 --- /dev/null +++ b/app/schemas/com.paulcoding.hviewer.database.AppDatabase/2.json @@ -0,0 +1,52 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "f8c3310b39b34e0c6c9c004f8557d416", + "entities": [ + { + "tableName": "favorite_posts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnail` TEXT NOT NULL, `site` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "site", + "columnName": "site", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "url" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f8c3310b39b34e0c6c9c004f8557d416')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/database/AppDatabase.kt b/app/src/main/java/com/paulcoding/hviewer/database/AppDatabase.kt index b25dcf3..09a351f 100644 --- a/app/src/main/java/com/paulcoding/hviewer/database/AppDatabase.kt +++ b/app/src/main/java/com/paulcoding/hviewer/database/AppDatabase.kt @@ -4,7 +4,7 @@ import androidx.room.Database import androidx.room.RoomDatabase import com.paulcoding.hviewer.model.PostItem -@Database(entities = [PostItem::class], version = 1) +@Database(entities = [PostItem::class], version = 2, exportSchema = true) abstract class AppDatabase : RoomDatabase() { abstract fun favoritePostDao(): FavoritePostDao } \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/database/DatabaseProvider.kt b/app/src/main/java/com/paulcoding/hviewer/database/DatabaseProvider.kt index 9d87064..16b7760 100644 --- a/app/src/main/java/com/paulcoding/hviewer/database/DatabaseProvider.kt +++ b/app/src/main/java/com/paulcoding/hviewer/database/DatabaseProvider.kt @@ -11,7 +11,9 @@ object DatabaseProvider { db = Room.databaseBuilder( appContext, AppDatabase::class.java, "hviewer_db" - ).build() + ) + .addMigrations(MIGRATION_1_2) + .build() } return db!! } diff --git a/app/src/main/java/com/paulcoding/hviewer/database/Migrations.kt b/app/src/main/java/com/paulcoding/hviewer/database/Migrations.kt new file mode 100644 index 0000000..f83debb --- /dev/null +++ b/app/src/main/java/com/paulcoding/hviewer/database/Migrations.kt @@ -0,0 +1,10 @@ +package com.paulcoding.hviewer.database + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE favorite_posts ADD COLUMN site TEXT NOT NULL DEFAULT \"\"") + } +} diff --git a/app/src/main/java/com/paulcoding/hviewer/model/PostModel.kt b/app/src/main/java/com/paulcoding/hviewer/model/PostModel.kt index 7219f6e..4c69e8f 100644 --- a/app/src/main/java/com/paulcoding/hviewer/model/PostModel.kt +++ b/app/src/main/java/com/paulcoding/hviewer/model/PostModel.kt @@ -16,6 +16,7 @@ data class PostItem( val url: String = "", val name: String = "", val thumbnail: String = "", + val site: String = "", ) data class Posts( diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/AppEntry.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppEntry.kt index 23fc26f..eb50989 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/AppEntry.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppEntry.kt @@ -45,7 +45,7 @@ fun AppEntry() { SitesPage(siteConfigs = siteConfigs, refresh = { Github.refreshLocalConfigs() }, navToTopics = { site -> - appViewModel.setSiteConfig(siteConfigs.sites[site]!!) + appViewModel.setSiteConfig(site, siteConfigs.sites[site]!!) navController.navigate(Route.POSTS) }, navToSettings = { navController.navigate(Route.SETTINGS) @@ -88,6 +88,7 @@ fun AppEntry() { FavoritePage( appViewModel = appViewModel, navToImages = { post: PostItem -> + appViewModel.setSiteConfig(post.site, siteConfigs.sites[post.site]!!) navToImages(post) }, goBack = { navController.popBackStack() } diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt index 2ea2d6b..d0fc1fa 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt @@ -20,18 +20,20 @@ class AppViewModel : ViewModel() { _stateFlow.update { it.copy(post = post) } } - fun setSiteConfig(siteConfig: SiteConfig) { - _stateFlow.update { it.copy(siteConfig = siteConfig) } + fun setSiteConfig(site: String, siteConfig: SiteConfig) { + _stateFlow.update { it.copy(site = site, siteConfig = siteConfig) } } data class UiState( val post: PostItem = PostItem(), + val site: String = "", val siteConfig: SiteConfig = SiteConfig(), ) fun addFavorite(postItem: PostItem) { viewModelScope.launch { - DatabaseProvider.getInstance().favoritePostDao().insert(postItem) + val postWithPage = postItem.copy(site = _stateFlow.value.site) + DatabaseProvider.getInstance().favoritePostDao().insert(postWithPage) } } From a46dcf612d551ed447a74920bea16cb4efb80847 Mon Sep 17 00:00:00 2001 From: longnghia <41385034+longnghia@users.noreply.github.com> Date: Tue, 3 Dec 2024 14:39:53 +0700 Subject: [PATCH 3/6] Use Pair of site and siteConfig in app viewModel --- .../java/com/paulcoding/hviewer/ui/page/AppViewModel.kt | 7 +++---- .../java/com/paulcoding/hviewer/ui/page/post/PostPage.kt | 2 +- .../java/com/paulcoding/hviewer/ui/page/posts/PostsPage.kt | 2 +- .../com/paulcoding/hviewer/ui/page/search/SearchPage.kt | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt index d0fc1fa..8d338a4 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt @@ -21,18 +21,17 @@ class AppViewModel : ViewModel() { } fun setSiteConfig(site: String, siteConfig: SiteConfig) { - _stateFlow.update { it.copy(site = site, siteConfig = siteConfig) } + _stateFlow.update { it.copy(site = site to siteConfig) } } data class UiState( val post: PostItem = PostItem(), - val site: String = "", - val siteConfig: SiteConfig = SiteConfig(), + val site: Pair = "" to SiteConfig(), ) fun addFavorite(postItem: PostItem) { viewModelScope.launch { - val postWithPage = postItem.copy(site = _stateFlow.value.site) + val postWithPage = postItem.copy(site = _stateFlow.value.site.first) DatabaseProvider.getInstance().favoritePostDao().insert(postWithPage) } } diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/post/PostPage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/post/PostPage.kt index f8a9124..3c1af2c 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/post/PostPage.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/post/PostPage.kt @@ -42,7 +42,7 @@ import me.saket.telephoto.zoomable.zoomable fun PostPage(appViewModel: AppViewModel, goBack: () -> Unit) { val appState by appViewModel.stateFlow.collectAsState() val post = appState.post - val siteConfig = appState.siteConfig + val siteConfig = appState.site.second val viewModel: PostViewModel = viewModel( factory = PostViewModelFactory(post.url, siteConfig = siteConfig) diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsPage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsPage.kt index d16f8de..f1b6004 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsPage.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsPage.kt @@ -61,7 +61,7 @@ fun PostsPage( goBack: () -> Unit ) { val appState by appViewModel.stateFlow.collectAsState() - val siteConfig = appState.siteConfig + val siteConfig = appState.site.second val listTopic = siteConfig.tags.keys.toList() val pagerState = rememberPagerState { listTopic.size } diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/search/SearchPage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/search/SearchPage.kt index 2bdb53d..a62f7d4 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/search/SearchPage.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/search/SearchPage.kt @@ -55,7 +55,7 @@ fun SearchPage( goBack: () -> Unit, ) { val appState by appViewModel.stateFlow.collectAsState() - val siteConfig = appState.siteConfig + val siteConfig = appState.site.second val viewModel: SearchViewModel = viewModel( factory = SearchViewModelFactory(siteConfig), From 16a00189599796cc3faddbc456577490ba971cee Mon Sep 17 00:00:00 2001 From: longnghia <41385034+longnghia@users.noreply.github.com> Date: Tue, 3 Dec 2024 15:12:45 +0700 Subject: [PATCH 4/6] Sort favorite by createdAt --- .../3.json | 58 +++++++++++++++++++ .../hviewer/database/AppDatabase.kt | 2 +- .../hviewer/database/DatabaseProvider.kt | 2 +- .../hviewer/database/FavoriteDao.kt | 2 +- .../paulcoding/hviewer/database/Migrations.kt | 6 ++ .../com/paulcoding/hviewer/model/PostModel.kt | 1 + 6 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 app/schemas/com.paulcoding.hviewer.database.AppDatabase/3.json diff --git a/app/schemas/com.paulcoding.hviewer.database.AppDatabase/3.json b/app/schemas/com.paulcoding.hviewer.database.AppDatabase/3.json new file mode 100644 index 0000000..1117531 --- /dev/null +++ b/app/schemas/com.paulcoding.hviewer.database.AppDatabase/3.json @@ -0,0 +1,58 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "40109ede6ba29fdee1d23735114a2981", + "entities": [ + { + "tableName": "favorite_posts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnail` TEXT NOT NULL, `site` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "site", + "columnName": "site", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "url" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '40109ede6ba29fdee1d23735114a2981')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/database/AppDatabase.kt b/app/src/main/java/com/paulcoding/hviewer/database/AppDatabase.kt index 09a351f..ff938f9 100644 --- a/app/src/main/java/com/paulcoding/hviewer/database/AppDatabase.kt +++ b/app/src/main/java/com/paulcoding/hviewer/database/AppDatabase.kt @@ -4,7 +4,7 @@ import androidx.room.Database import androidx.room.RoomDatabase import com.paulcoding.hviewer.model.PostItem -@Database(entities = [PostItem::class], version = 2, exportSchema = true) +@Database(entities = [PostItem::class], version = 3, exportSchema = true) abstract class AppDatabase : RoomDatabase() { abstract fun favoritePostDao(): FavoritePostDao } \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/database/DatabaseProvider.kt b/app/src/main/java/com/paulcoding/hviewer/database/DatabaseProvider.kt index 16b7760..3acf68c 100644 --- a/app/src/main/java/com/paulcoding/hviewer/database/DatabaseProvider.kt +++ b/app/src/main/java/com/paulcoding/hviewer/database/DatabaseProvider.kt @@ -12,7 +12,7 @@ object DatabaseProvider { appContext, AppDatabase::class.java, "hviewer_db" ) - .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .build() } return db!! diff --git a/app/src/main/java/com/paulcoding/hviewer/database/FavoriteDao.kt b/app/src/main/java/com/paulcoding/hviewer/database/FavoriteDao.kt index 2cf0e84..fdaa946 100644 --- a/app/src/main/java/com/paulcoding/hviewer/database/FavoriteDao.kt +++ b/app/src/main/java/com/paulcoding/hviewer/database/FavoriteDao.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow @Dao interface FavoritePostDao { - @Query("SELECT * FROM favorite_posts") + @Query("SELECT * FROM favorite_posts ORDER BY createdAt DESC") fun getAll(): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) diff --git a/app/src/main/java/com/paulcoding/hviewer/database/Migrations.kt b/app/src/main/java/com/paulcoding/hviewer/database/Migrations.kt index f83debb..172fb76 100644 --- a/app/src/main/java/com/paulcoding/hviewer/database/Migrations.kt +++ b/app/src/main/java/com/paulcoding/hviewer/database/Migrations.kt @@ -8,3 +8,9 @@ val MIGRATION_1_2 = object : Migration(1, 2) { db.execSQL("ALTER TABLE favorite_posts ADD COLUMN site TEXT NOT NULL DEFAULT \"\"") } } + +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE favorite_posts ADD COLUMN createdAt INTEGER NOT NULL DEFAULT 0") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/paulcoding/hviewer/model/PostModel.kt b/app/src/main/java/com/paulcoding/hviewer/model/PostModel.kt index 4c69e8f..4dfe424 100644 --- a/app/src/main/java/com/paulcoding/hviewer/model/PostModel.kt +++ b/app/src/main/java/com/paulcoding/hviewer/model/PostModel.kt @@ -17,6 +17,7 @@ data class PostItem( val name: String = "", val thumbnail: String = "", val site: String = "", + val createdAt: Long = 0, ) data class Posts( From b8a2b665e9b49ddeb88d210f7e25bf52503bdbc7 Mon Sep 17 00:00:00 2001 From: longnghia <41385034+longnghia@users.noreply.github.com> Date: Tue, 3 Dec 2024 15:13:36 +0700 Subject: [PATCH 5/6] Enable Undo removing favorite post --- .../hviewer/ui/favorite/FavoritePage.kt | 44 ++++++++++++++++--- .../hviewer/ui/page/AppViewModel.kt | 9 ++-- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/favorite/FavoritePage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/favorite/FavoritePage.kt index a9efdd2..c2cff83 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/favorite/FavoritePage.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/favorite/FavoritePage.kt @@ -6,11 +6,17 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.compose.viewModel import com.paulcoding.hviewer.model.PostItem @@ -18,6 +24,7 @@ import com.paulcoding.hviewer.ui.component.HBackIcon import com.paulcoding.hviewer.ui.component.HEmpty import com.paulcoding.hviewer.ui.page.AppViewModel import com.paulcoding.hviewer.ui.page.posts.PostCard +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -27,19 +34,42 @@ fun FavoritePage( goBack: () -> Boolean ) { val viewModel: AppViewModel = viewModel() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() val favoritePosts by viewModel.favoritePosts.collectAsState(initial = emptyList()) + fun onDelete(post: PostItem) { + appViewModel.deleteFavorite(post) + + scope.launch { + val result = snackbarHostState.showSnackbar( + "${post.name} removed from favorite", + "Undo", + duration = SnackbarDuration.Short + ) + when (result) { + SnackbarResult.ActionPerformed -> { + appViewModel.addFavorite(post, true) + } + + SnackbarResult.Dismissed -> { + } + } + } + } + Scaffold( modifier = Modifier.fillMaxSize(), + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar(title = { Text("Favorite") }, navigationIcon = { HBackIcon { goBack() } }) }) { paddings -> LazyColumn(modifier = Modifier.padding(paddings)) { - items(items = favoritePosts.reversed(), key = { it.url }) { item -> - FavoriteItem(item, navToImages = { navToImages(it) }, deleteFavorite = { - appViewModel.deleteFavorite(it) + items(items = favoritePosts, key = { it.url }) { item -> + FavoriteItem(item, navToImages = { navToImages(item) }, deleteFavorite = { + onDelete(item) }) } if (favoritePosts.isEmpty()) @@ -52,12 +82,12 @@ fun FavoritePage( @Composable fun FavoriteItem( post: PostItem, - navToImages: (PostItem) -> Unit, - deleteFavorite: (PostItem) -> Unit + navToImages: () -> Unit, + deleteFavorite: () -> Unit ) { PostCard(post, isFavorite = true, setFavorite = { - deleteFavorite(post) + deleteFavorite() }) { - navToImages(post) + navToImages() } } diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt index 8d338a4..030e83e 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/AppViewModel.kt @@ -29,10 +29,13 @@ class AppViewModel : ViewModel() { val site: Pair = "" to SiteConfig(), ) - fun addFavorite(postItem: PostItem) { + fun addFavorite(postItem: PostItem, reAdded: Boolean = false) { viewModelScope.launch { - val postWithPage = postItem.copy(site = _stateFlow.value.site.first) - DatabaseProvider.getInstance().favoritePostDao().insert(postWithPage) + val item = if (reAdded) postItem else postItem.copy( + site = _stateFlow.value.site.first, + createdAt = System.currentTimeMillis() + ) + DatabaseProvider.getInstance().favoritePostDao().insert(item) } } From c764c4e5b3edd26f7a8bb4a3c7b57ea3b3971457 Mon Sep 17 00:00:00 2001 From: longnghia <41385034+longnghia@users.noreply.github.com> Date: Tue, 3 Dec 2024 15:34:59 +0700 Subject: [PATCH 6/6] Add small animation for PostCard --- .../java/com/paulcoding/hviewer/ui/page/posts/PostsPage.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsPage.kt b/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsPage.kt index f1b6004..28e8e4f 100644 --- a/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsPage.kt +++ b/app/src/main/java/com/paulcoding/hviewer/ui/page/posts/PostsPage.kt @@ -1,6 +1,8 @@ package com.paulcoding.hviewer.ui.page.posts import android.widget.Toast +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -200,7 +202,10 @@ fun PostCard( Card( elevation = CardDefaults.cardElevation(8.dp), modifier = Modifier - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 12.dp) + .animateContentSize( + animationSpec = tween(durationMillis = 300) + ), shape = MaterialTheme.shapes.medium, ) { Box(modifier = Modifier.fillMaxSize()) {