From 260765acff2e7cc597bc445ca0b0b527654193be Mon Sep 17 00:00:00 2001 From: SkyD666 Date: Fri, 16 Feb 2024 23:04:06 +0800 Subject: [PATCH] [feature] Support search feeds or articles --- .../preference => base}/BasePreference.kt | 2 +- .../com/skyd/anivu/config/SearchConfig.kt | 23 +++ .../java/com/skyd/anivu/di/DatabaseModule.kt | 14 ++ .../main/java/com/skyd/anivu/ext/BundleExt.kt | 9 + .../java/com/skyd/anivu/ext/DataStoreExt.kt | 57 ++++++ .../skyd/anivu/model/bean/SearchDomainBean.kt | 29 +++ .../com/skyd/anivu/model/db/AppDatabase.kt | 4 +- .../anivu/model/db/SearchDomainDatabase.kt | 53 +++++ .../com/skyd/anivu/model/db/dao/ArticleDao.kt | 9 +- .../com/skyd/anivu/model/db/dao/FeedDao.kt | 6 + .../anivu/model/db/dao/SearchDomainDao.kt | 41 ++++ .../IntersectSearchBySpacePreference.kt | 26 +++ .../search/UseRegexSearchPreference.kt | 26 +++ .../model/repository/SearchRepository.kt | 186 ++++++++++++++++++ .../ui/adapter/variety/proxy/Feed1Proxy.kt | 29 ++- .../anivu/ui/fragment/about/AboutFragment.kt | 1 + .../ui/fragment/article/ArticleFragment.kt | 19 ++ .../ui/fragment/download/DownloadViewModel.kt | 4 +- .../anivu/ui/fragment/feed/FeedFragment.kt | 22 +++ .../ui/fragment/search/SearchFragment.kt | 139 +++++++++++++ .../anivu/ui/fragment/search/SearchIntent.kt | 10 + .../search/SearchPartialStateChange.kt | 28 +++ .../anivu/ui/fragment/search/SearchState.kt | 20 ++ .../ui/fragment/search/SearchViewModel.kt | 79 ++++++++ app/src/main/res/drawable/ic_search_24.xml | 10 + app/src/main/res/layout/fragment_about.xml | 5 +- app/src/main/res/layout/fragment_article.xml | 5 +- app/src/main/res/layout/fragment_download.xml | 5 +- app/src/main/res/layout/fragment_feed.xml | 5 +- app/src/main/res/layout/fragment_license.xml | 4 +- app/src/main/res/layout/fragment_media.xml | 4 +- app/src/main/res/layout/fragment_more.xml | 4 +- app/src/main/res/layout/fragment_read.xml | 10 +- app/src/main/res/layout/fragment_search.xml | 60 ++++++ app/src/main/res/menu/menu_article.xml | 9 + app/src/main/res/menu/menu_feed.xml | 9 + app/src/main/res/navigation/nav_graph.xml | 13 ++ app/src/main/res/values-zh-rCN/strings.xml | 5 + app/src/main/res/values/strings.xml | 5 + app/src/main/res/values/styles.xml | 16 ++ app/src/main/res/values/themes.xml | 1 + 41 files changed, 981 insertions(+), 25 deletions(-) rename app/src/main/java/com/skyd/anivu/{model/preference => base}/BasePreference.kt (80%) create mode 100644 app/src/main/java/com/skyd/anivu/config/SearchConfig.kt create mode 100644 app/src/main/java/com/skyd/anivu/ext/BundleExt.kt create mode 100644 app/src/main/java/com/skyd/anivu/ext/DataStoreExt.kt create mode 100644 app/src/main/java/com/skyd/anivu/model/bean/SearchDomainBean.kt create mode 100644 app/src/main/java/com/skyd/anivu/model/db/SearchDomainDatabase.kt create mode 100644 app/src/main/java/com/skyd/anivu/model/db/dao/SearchDomainDao.kt create mode 100644 app/src/main/java/com/skyd/anivu/model/preference/search/IntersectSearchBySpacePreference.kt create mode 100644 app/src/main/java/com/skyd/anivu/model/preference/search/UseRegexSearchPreference.kt create mode 100644 app/src/main/java/com/skyd/anivu/model/repository/SearchRepository.kt create mode 100644 app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchFragment.kt create mode 100644 app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchIntent.kt create mode 100644 app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchPartialStateChange.kt create mode 100644 app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchState.kt create mode 100644 app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchViewModel.kt create mode 100644 app/src/main/res/drawable/ic_search_24.xml create mode 100644 app/src/main/res/layout/fragment_search.xml create mode 100644 app/src/main/res/menu/menu_article.xml create mode 100644 app/src/main/res/menu/menu_feed.xml diff --git a/app/src/main/java/com/skyd/anivu/model/preference/BasePreference.kt b/app/src/main/java/com/skyd/anivu/base/BasePreference.kt similarity index 80% rename from app/src/main/java/com/skyd/anivu/model/preference/BasePreference.kt rename to app/src/main/java/com/skyd/anivu/base/BasePreference.kt index 1a1ecf8e..a3399e8e 100644 --- a/app/src/main/java/com/skyd/anivu/model/preference/BasePreference.kt +++ b/app/src/main/java/com/skyd/anivu/base/BasePreference.kt @@ -1,4 +1,4 @@ -package com.skyd.anivu.model.preference +package com.skyd.anivu.base import androidx.datastore.preferences.core.Preferences diff --git a/app/src/main/java/com/skyd/anivu/config/SearchConfig.kt b/app/src/main/java/com/skyd/anivu/config/SearchConfig.kt new file mode 100644 index 00000000..9bade93e --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/config/SearchConfig.kt @@ -0,0 +1,23 @@ +package com.skyd.anivu.config + +import com.skyd.anivu.model.bean.ARTICLE_TABLE_NAME +import com.skyd.anivu.model.bean.ArticleBean +import com.skyd.anivu.model.bean.FEED_TABLE_NAME +import com.skyd.anivu.model.bean.FeedBean + +val allSearchDomain: HashMap> = hashMapOf( + FEED_TABLE_NAME to listOf( + FeedBean.URL_COLUMN, + FeedBean.TITLE_COLUMN, + FeedBean.DESCRIPTION_COLUMN, + FeedBean.LINK_COLUMN, + FeedBean.ICON_COLUMN, + ), + ARTICLE_TABLE_NAME to listOf( + ArticleBean.TITLE_COLUMN, + ArticleBean.AUTHOR_COLUMN, + ArticleBean.DESCRIPTION_COLUMN, + ArticleBean.CONTENT_COLUMN, + ArticleBean.LINK_COLUMN, + ), +) diff --git a/app/src/main/java/com/skyd/anivu/di/DatabaseModule.kt b/app/src/main/java/com/skyd/anivu/di/DatabaseModule.kt index 3e97349b..a76accdf 100644 --- a/app/src/main/java/com/skyd/anivu/di/DatabaseModule.kt +++ b/app/src/main/java/com/skyd/anivu/di/DatabaseModule.kt @@ -2,10 +2,12 @@ package com.skyd.anivu.di import android.content.Context import com.skyd.anivu.model.db.AppDatabase +import com.skyd.anivu.model.db.SearchDomainDatabase import com.skyd.anivu.model.db.dao.ArticleDao import com.skyd.anivu.model.db.dao.DownloadInfoDao import com.skyd.anivu.model.db.dao.EnclosureDao import com.skyd.anivu.model.db.dao.FeedDao +import com.skyd.anivu.model.db.dao.SearchDomainDao import com.skyd.anivu.model.db.dao.SessionParamsDao import dagger.Module import dagger.Provides @@ -43,4 +45,16 @@ object DatabaseModule { @Singleton fun provideSessionParamsDao(database: AppDatabase): SessionParamsDao = database.sessionParamsDao() + + + @Provides + @Singleton + fun provideSearchDomainDatabase(@ApplicationContext context: Context): SearchDomainDatabase = + SearchDomainDatabase.getInstance(context) + + @Provides + @Singleton + fun provideSearchDomain(database: SearchDomainDatabase): SearchDomainDao = + database.searchDomainDao() + } diff --git a/app/src/main/java/com/skyd/anivu/ext/BundleExt.kt b/app/src/main/java/com/skyd/anivu/ext/BundleExt.kt new file mode 100644 index 00000000..584b1b72 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ext/BundleExt.kt @@ -0,0 +1,9 @@ +package com.skyd.anivu.ext + +import android.os.Bundle + +inline fun > Bundle.getEnum(key: String, default: T) = + getInt(key, -1).let { if (it >= 0) enumValues()[it] else default } + +fun > Bundle.putEnum(key: String, value: T?) = + putInt(key, value?.ordinal ?: -1) \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ext/DataStoreExt.kt b/app/src/main/java/com/skyd/anivu/ext/DataStoreExt.kt new file mode 100644 index 00000000..1192b089 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ext/DataStoreExt.kt @@ -0,0 +1,57 @@ +package com.skyd.anivu.ext + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.preferencesDataStore +import com.skyd.anivu.base.BasePreference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.io.IOException + +val Context.dataStore: DataStore by preferencesDataStore(name = "App") + +suspend fun DataStore.put(key: Preferences.Key, value: T) { + this.edit { + withContext(Dispatchers.IO) { + it[key] = value + } + } +} + +@Suppress("UNCHECKED_CAST") +fun DataStore.getOrNull(key: Preferences.Key): T? { + return runBlocking { + this@getOrNull.data.catch { exception -> + if (exception is IOException) { + exception.printStackTrace() + emit(emptyPreferences()) + } else { + throw exception + } + }.map { + it[key] + }.first() as T + } +} + +fun DataStore.getOrDefault(pref: BasePreference): T { + return runBlocking { + this@getOrDefault.data.catch { exception -> + if (exception is IOException) { + exception.printStackTrace() + emit(emptyPreferences()) + } else { + throw exception + } + }.map { + pref.fromPreferences(it) + }.first() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/model/bean/SearchDomainBean.kt b/app/src/main/java/com/skyd/anivu/model/bean/SearchDomainBean.kt new file mode 100644 index 00000000..a5bd4639 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/model/bean/SearchDomainBean.kt @@ -0,0 +1,29 @@ +package com.skyd.anivu.model.bean + +import androidx.room.ColumnInfo +import androidx.room.Entity +import com.skyd.anivu.base.BaseBean +import kotlinx.serialization.Serializable + +const val SEARCH_DOMAIN_TABLE_NAME = "SearchDomain" + +@Serializable +@Entity( + tableName = SEARCH_DOMAIN_TABLE_NAME, + primaryKeys = [SearchDomainBean.TABLE_NAME_COLUMN, SearchDomainBean.COLUMN_NAME_COLUMN] +) +data class SearchDomainBean( + @ColumnInfo(name = TABLE_NAME_COLUMN) + var tableName: String, + @ColumnInfo(name = COLUMN_NAME_COLUMN) + var columnName: String, + @ColumnInfo(name = SEARCH_COLUMN) + var search: Boolean, +) : BaseBean { + companion object { + const val TABLE_NAME_COLUMN = "tableName" + const val COLUMN_NAME_COLUMN = "columnName" + const val SEARCH_COLUMN = "search" + } +} + diff --git a/app/src/main/java/com/skyd/anivu/model/db/AppDatabase.kt b/app/src/main/java/com/skyd/anivu/model/db/AppDatabase.kt index 32e13a57..5dce7790 100644 --- a/app/src/main/java/com/skyd/anivu/model/db/AppDatabase.kt +++ b/app/src/main/java/com/skyd/anivu/model/db/AppDatabase.kt @@ -7,15 +7,15 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.Migration import com.skyd.anivu.model.bean.ArticleBean +import com.skyd.anivu.model.bean.DownloadInfoBean import com.skyd.anivu.model.bean.EnclosureBean import com.skyd.anivu.model.bean.FeedBean import com.skyd.anivu.model.bean.SessionParamsBean -import com.skyd.anivu.model.bean.DownloadInfoBean import com.skyd.anivu.model.db.dao.ArticleDao +import com.skyd.anivu.model.db.dao.DownloadInfoDao import com.skyd.anivu.model.db.dao.EnclosureDao import com.skyd.anivu.model.db.dao.FeedDao import com.skyd.anivu.model.db.dao.SessionParamsDao -import com.skyd.anivu.model.db.dao.DownloadInfoDao const val APP_DATA_BASE_FILE_NAME = "app.db" diff --git a/app/src/main/java/com/skyd/anivu/model/db/SearchDomainDatabase.kt b/app/src/main/java/com/skyd/anivu/model/db/SearchDomainDatabase.kt new file mode 100644 index 00000000..4c37c3db --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/model/db/SearchDomainDatabase.kt @@ -0,0 +1,53 @@ +package com.skyd.anivu.model.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.room.migration.Migration +import com.skyd.anivu.model.bean.SearchDomainBean +import com.skyd.anivu.model.db.dao.SearchDomainDao + +const val SEARCH_DOMAIN_DATA_BASE_FILE_NAME = "searchDomain.db" + +@Database( + entities = [ + SearchDomainBean::class, + ], + version = 1 +) +@TypeConverters( + value = [] +) +abstract class SearchDomainDatabase : RoomDatabase() { + + abstract fun searchDomainDao(): SearchDomainDao + + companion object { + @Volatile + private var instance: SearchDomainDatabase? = null + + private val migrations = arrayOf() + + fun getInstance(context: Context): SearchDomainDatabase { + return if (instance == null) { + synchronized(this) { + if (instance == null) { + Room.databaseBuilder( + context.applicationContext, + SearchDomainDatabase::class.java, + SEARCH_DOMAIN_DATA_BASE_FILE_NAME + ) + .addMigrations(*migrations) + .build() + } else { + instance as SearchDomainDatabase + } + } + } else { + instance as SearchDomainDatabase + } + } + } +} diff --git a/app/src/main/java/com/skyd/anivu/model/db/dao/ArticleDao.kt b/app/src/main/java/com/skyd/anivu/model/db/dao/ArticleDao.kt index 88ef83fc..bddcb076 100644 --- a/app/src/main/java/com/skyd/anivu/model/db/dao/ArticleDao.kt +++ b/app/src/main/java/com/skyd/anivu/model/db/dao/ArticleDao.kt @@ -5,15 +5,16 @@ import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.RawQuery import androidx.room.RewriteQueriesToDropUnusedColumns import androidx.room.Transaction +import androidx.sqlite.db.SupportSQLiteQuery import com.skyd.anivu.appContext import com.skyd.anivu.model.bean.ARTICLE_TABLE_NAME import com.skyd.anivu.model.bean.ArticleBean import com.skyd.anivu.model.bean.ArticleWithEnclosureBean import com.skyd.anivu.model.bean.FEED_TABLE_NAME import com.skyd.anivu.model.bean.FeedBean -import com.skyd.anivu.model.db.APP_DATA_BASE_FILE_NAME import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -76,12 +77,16 @@ interface ArticleDao { @Query( """ SELECT * FROM $ARTICLE_TABLE_NAME - WHERE ${ArticleBean.FEED_URL_COLUMN} LIKE :feedUrl + WHERE ${ArticleBean.FEED_URL_COLUMN} = :feedUrl ORDER BY ${ArticleBean.DATE_COLUMN} DESC """ ) fun getArticleList(feedUrl: String): Flow> + @Transaction + @RawQuery(observedEntities = [ArticleBean::class]) + fun getArticleList(sql: SupportSQLiteQuery): Flow> + @Transaction @Query( """ diff --git a/app/src/main/java/com/skyd/anivu/model/db/dao/FeedDao.kt b/app/src/main/java/com/skyd/anivu/model/db/dao/FeedDao.kt index c405d3b3..8fd32a5c 100644 --- a/app/src/main/java/com/skyd/anivu/model/db/dao/FeedDao.kt +++ b/app/src/main/java/com/skyd/anivu/model/db/dao/FeedDao.kt @@ -5,7 +5,9 @@ import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.RawQuery import androidx.room.Transaction +import androidx.sqlite.db.SupportSQLiteQuery import com.skyd.anivu.appContext import com.skyd.anivu.model.bean.FEED_TABLE_NAME import com.skyd.anivu.model.bean.FeedBean @@ -68,4 +70,8 @@ interface FeedDao { @Transaction @Query("SELECT * FROM $FEED_TABLE_NAME WHERE ${FeedBean.URL_COLUMN} = :feedUrl") suspend fun getFeed(feedUrl: String): FeedBean + + @Transaction + @RawQuery(observedEntities = [FeedBean::class]) + fun getFeedList(sql: SupportSQLiteQuery): Flow> } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/model/db/dao/SearchDomainDao.kt b/app/src/main/java/com/skyd/anivu/model/db/dao/SearchDomainDao.kt new file mode 100644 index 00000000..04ea53dd --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/model/db/dao/SearchDomainDao.kt @@ -0,0 +1,41 @@ +package com.skyd.anivu.model.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.skyd.anivu.model.bean.SEARCH_DOMAIN_TABLE_NAME +import com.skyd.anivu.model.bean.SearchDomainBean +import com.skyd.anivu.model.bean.SearchDomainBean.Companion.COLUMN_NAME_COLUMN +import com.skyd.anivu.model.bean.SearchDomainBean.Companion.SEARCH_COLUMN +import com.skyd.anivu.model.bean.SearchDomainBean.Companion.TABLE_NAME_COLUMN + +@Dao +interface SearchDomainDao { + @Transaction + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun setSearchDomain(searchDomainBean: SearchDomainBean) + + @Transaction + @Query( + """SELECT $SEARCH_COLUMN FROM $SEARCH_DOMAIN_TABLE_NAME + WHERE $TABLE_NAME_COLUMN LIKE :tableName AND $COLUMN_NAME_COLUMN LIKE :columnName""" + ) + fun getSearchDomainOrNull(tableName: String, columnName: String): Boolean? + + // 被选择的搜索域的个数 + @Transaction + @Query("SELECT COUNT(*) FROM $SEARCH_DOMAIN_TABLE_NAME WHERE $SEARCH_COLUMN = 1") + fun selectedSearchDomainCount(): Int + + @Transaction + fun getSearchDomain(tableName: String, columnName: String): Boolean { + val result = getSearchDomainOrNull(tableName, columnName) + return result == true || selectedSearchDomainCount() == 0 + } + + @Transaction + @Query(value = "SELECT * FROM $SEARCH_DOMAIN_TABLE_NAME") + fun getAllSearchDomain(): List +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/model/preference/search/IntersectSearchBySpacePreference.kt b/app/src/main/java/com/skyd/anivu/model/preference/search/IntersectSearchBySpacePreference.kt new file mode 100644 index 00000000..a4651bdf --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/model/preference/search/IntersectSearchBySpacePreference.kt @@ -0,0 +1,26 @@ +package com.skyd.anivu.model.preference.search + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import com.skyd.anivu.base.BasePreference +import com.skyd.anivu.ext.dataStore +import com.skyd.anivu.ext.put +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +object IntersectSearchBySpacePreference : BasePreference { + private const val INTERSECT_SEARCH_BY_SPACE = "intersectSearchBySpace" + override val default = true + + val key = booleanPreferencesKey(INTERSECT_SEARCH_BY_SPACE) + + fun put(context: Context, scope: CoroutineScope, value: Boolean) { + scope.launch(Dispatchers.IO) { + context.dataStore.put(key, value) + } + } + + override fun fromPreferences(preferences: Preferences): Boolean = preferences[key] ?: default +} diff --git a/app/src/main/java/com/skyd/anivu/model/preference/search/UseRegexSearchPreference.kt b/app/src/main/java/com/skyd/anivu/model/preference/search/UseRegexSearchPreference.kt new file mode 100644 index 00000000..aa63a96d --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/model/preference/search/UseRegexSearchPreference.kt @@ -0,0 +1,26 @@ +package com.skyd.anivu.model.preference.search + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import com.skyd.anivu.base.BasePreference +import com.skyd.anivu.ext.dataStore +import com.skyd.anivu.ext.put +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +object UseRegexSearchPreference : BasePreference { + private const val USE_REGEX_SEARCH = "useRegexSearch" + override val default = false + + val key = booleanPreferencesKey(USE_REGEX_SEARCH) + + fun put(context: Context, scope: CoroutineScope, value: Boolean) { + scope.launch(Dispatchers.IO) { + context.dataStore.put(key, value) + } + } + + override fun fromPreferences(preferences: Preferences): Boolean = preferences[key] ?: default +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/model/repository/SearchRepository.kt b/app/src/main/java/com/skyd/anivu/model/repository/SearchRepository.kt new file mode 100644 index 00000000..353dc8b6 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/model/repository/SearchRepository.kt @@ -0,0 +1,186 @@ +package com.skyd.anivu.model.repository + +import android.database.DatabaseUtils +import androidx.sqlite.db.SimpleSQLiteQuery +import com.skyd.anivu.appContext +import com.skyd.anivu.base.BaseRepository +import com.skyd.anivu.config.allSearchDomain +import com.skyd.anivu.ext.dataStore +import com.skyd.anivu.ext.getOrDefault +import com.skyd.anivu.model.bean.ARTICLE_TABLE_NAME +import com.skyd.anivu.model.bean.ArticleBean +import com.skyd.anivu.model.bean.FEED_TABLE_NAME +import com.skyd.anivu.model.bean.FeedBean +import com.skyd.anivu.model.db.dao.ArticleDao +import com.skyd.anivu.model.db.dao.FeedDao +import com.skyd.anivu.model.db.dao.SearchDomainDao +import com.skyd.anivu.model.preference.search.IntersectSearchBySpacePreference +import com.skyd.anivu.model.preference.search.UseRegexSearchPreference +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.zip +import javax.inject.Inject + +class SearchRepository @Inject constructor( + private val feedDao: FeedDao, + private val articleDao: ArticleDao, +) : BaseRepository() { + fun requestSearchAll(query: String): Flow> { + return requestSearchFeed(query = query) + .zip(requestSearchArticle(feedUrl = null, query = query)) { feed, article -> + feed + article + }.flowOn(Dispatchers.IO) + } + + fun requestSearchFeed( + query: String, + ): Flow> { + return flow { emit(genSql(tableName = FEED_TABLE_NAME, k = query)) }.flatMapConcat { sql -> + feedDao.getFeedList(sql).take(1) + }.flowOn(Dispatchers.IO) + } + + fun requestSearchArticle( + feedUrl: String? = null, + query: String, + ): Flow> { + return flow { + emit( + genSql( + tableName = ARTICLE_TABLE_NAME, + k = query, + leadingFilter = if (feedUrl == null) "1" + else "${ArticleBean.FEED_URL_COLUMN} = ${DatabaseUtils.sqlEscapeString(feedUrl)}", + ) + ) + }.flatMapConcat { sql -> + articleDao.getArticleList(sql).take(1) + .map { list -> list.sortedByDescending { it.date } } + }.flowOn(Dispatchers.IO) + } + + class SearchRegexInvalidException(message: String?) : IllegalArgumentException(message) + + companion object { + @EntryPoint + @InstallIn(SingletonComponent::class) + interface SearchRepositoryEntryPoint { + val searchDomainDao: SearchDomainDao + } + + fun genSql( + tableName: String, + k: String, + useRegexSearch: Boolean = appContext.dataStore.getOrDefault(UseRegexSearchPreference), + // 是否使用多个关键字并集查询 + intersectSearchBySpace: Boolean = appContext.dataStore + .getOrDefault(IntersectSearchBySpacePreference), + useSearchDomain: (table: String, column: String) -> Boolean = { table, column -> + EntryPointAccessors.fromApplication( + appContext, SearchRepositoryEntryPoint::class.java + ).searchDomainDao.getSearchDomain(table, column) + }, + leadingFilter: String = "1", + leadingFilterLogicalConnective: String = "AND", + ): SimpleSQLiteQuery { + if (useRegexSearch) { + // Check Regex format + runCatching { k.toRegex() }.onFailure { + throw SearchRegexInvalidException(it.message) + } + } + + return if (intersectSearchBySpace) { + // 以多个连续的空格/制表符/换行符分割 + val keywords = k.trim().split("\\s+".toRegex()).toSet() + val sql = buildString { + keywords.forEachIndexed { i, s -> + if (i > 0) append("INTERSECT \n") + append( + "SELECT * FROM $tableName WHERE ${ + getFilter( + tableName = tableName, + k = s, + useRegexSearch = useRegexSearch, + useSearchDomain = useSearchDomain, + leadingFilter = leadingFilter, + leadingFilterLogicalConnective = leadingFilterLogicalConnective, + ) + } \n" + ) + } + } + SimpleSQLiteQuery(sql) + } else { + val sql = buildString { + append( + "SELECT * FROM $tableName WHERE ${ + getFilter( + tableName = tableName, + k = k, + useRegexSearch = useRegexSearch, + useSearchDomain = useSearchDomain, + leadingFilter = leadingFilter, + leadingFilterLogicalConnective = leadingFilterLogicalConnective, + ) + } \n" + ) + } + + SimpleSQLiteQuery(sql) + } + } + + private fun getFilter( + tableName: String, + k: String, + useRegexSearch: Boolean, + useSearchDomain: (tableName: String, columnName: String) -> Boolean, + leadingFilter: String = "1", + leadingFilterLogicalConnective: String = "AND", + ): String { + if (k.isBlank()) return leadingFilter + + var filter = "0" + + // 转义输入,防止SQL注入 + val keyword = if (useRegexSearch) { + // Check Regex format + runCatching { k.toRegex() }.onFailure { + throw SearchRegexInvalidException(it.message) + } + DatabaseUtils.sqlEscapeString(k) + } else { + DatabaseUtils.sqlEscapeString("%$k%") + } + + val columns = allSearchDomain[tableName].orEmpty() + for (column in columns) { + if (!useSearchDomain(tableName, column)) { + continue + } + filter += if (useRegexSearch) { + " OR $column REGEXP $keyword" + } else { + " OR $column LIKE $keyword" + } + } + + if (filter == "0") { + filter += " OR 1" + } + filter = "$leadingFilter $leadingFilterLogicalConnective ($filter)" + return filter + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/adapter/variety/proxy/Feed1Proxy.kt b/app/src/main/java/com/skyd/anivu/ui/adapter/variety/proxy/Feed1Proxy.kt index e25918a6..76d419bc 100644 --- a/app/src/main/java/com/skyd/anivu/ui/adapter/variety/proxy/Feed1Proxy.kt +++ b/app/src/main/java/com/skyd/anivu/ui/adapter/variety/proxy/Feed1Proxy.kt @@ -6,13 +6,17 @@ import android.view.LayoutInflater import android.view.MenuItem import android.view.ViewGroup import androidx.appcompat.widget.PopupMenu +import androidx.core.view.updatePadding import androidx.navigation.Navigation.findNavController import com.skyd.anivu.R import com.skyd.anivu.databinding.ItemFeed1Binding import com.skyd.anivu.ext.activity +import com.skyd.anivu.ext.dp +import com.skyd.anivu.ext.gone import com.skyd.anivu.ext.readable import com.skyd.anivu.ext.toHtml import com.skyd.anivu.ext.tryAddIcon +import com.skyd.anivu.ext.visible import com.skyd.anivu.model.bean.FeedBean import com.skyd.anivu.ui.adapter.variety.Feed1ViewHolder import com.skyd.anivu.ui.adapter.variety.VarietyAdapter @@ -20,8 +24,8 @@ import com.skyd.anivu.ui.fragment.article.ArticleFragment class Feed1Proxy( - private val onRemove: (FeedBean) -> Unit, - private val onEdit: (FeedBean) -> Unit, + private val onRemove: ((FeedBean) -> Unit)? = null, + private val onEdit: ((FeedBean) -> Unit)? = null, ) : VarietyAdapter.Proxy() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = Feed1ViewHolder( @@ -38,25 +42,38 @@ class Feed1Proxy( holder.binding.apply { tvFeed1Title.text = data.title?.toHtml() tvFeed1Desc.text = data.description?.readable() + if (onRemove == null && onEdit == null) { + tvFeed1Title.updatePadding(right = 16.dp) + tvFeed1Desc.updatePadding(right = 16.dp) + btnFeed1Options.gone() + } else { + tvFeed1Title.updatePadding(right = 0) + tvFeed1Desc.updatePadding(right = 0) + btnFeed1Options.visible() + } btnFeed1Options.setOnClickListener { v -> val popup = PopupMenu(v.context, v) popup.menuInflater.inflate(R.menu.menu_feed_item, popup.menu) - popup.setOnMenuItemClickListener { menuItem: MenuItem -> when (menuItem.itemId) { R.id.action_feed_item_remove -> { - onRemove(data) + onRemove?.invoke(data) true } + R.id.action_feed_item_edit -> { - onEdit(data) + onEdit?.invoke(data) true } else -> false } } - popup.menu.tryAddIcon(v.context) + popup.menu.apply { + findItem(R.id.action_feed_item_remove).setVisible(onRemove != null) + findItem(R.id.action_feed_item_edit).setVisible(onEdit != null) + tryAddIcon(v.context) + } popup.show() } } diff --git a/app/src/main/java/com/skyd/anivu/ui/fragment/about/AboutFragment.kt b/app/src/main/java/com/skyd/anivu/ui/fragment/about/AboutFragment.kt index e8a530e4..6c8f388f 100644 --- a/app/src/main/java/com/skyd/anivu/ui/fragment/about/AboutFragment.kt +++ b/app/src/main/java/com/skyd/anivu/ui/fragment/about/AboutFragment.kt @@ -6,6 +6,7 @@ import android.view.ViewGroup import androidx.annotation.OptIn import androidx.appcompat.content.res.AppCompatResources import androidx.core.view.ViewCompat +import androidx.core.view.updatePadding import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.badge.BadgeDrawable diff --git a/app/src/main/java/com/skyd/anivu/ui/fragment/article/ArticleFragment.kt b/app/src/main/java/com/skyd/anivu/ui/fragment/article/ArticleFragment.kt index dc05b26a..18857271 100644 --- a/app/src/main/java/com/skyd/anivu/ui/fragment/article/ArticleFragment.kt +++ b/app/src/main/java/com/skyd/anivu/ui/fragment/article/ArticleFragment.kt @@ -22,6 +22,7 @@ import com.skyd.anivu.ext.startWith import com.skyd.anivu.ui.adapter.variety.AniSpanSize import com.skyd.anivu.ui.adapter.variety.VarietyAdapter import com.skyd.anivu.ui.adapter.variety.proxy.Article1Proxy +import com.skyd.anivu.ui.fragment.search.SearchFragment import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.consumeAsFlow @@ -109,6 +110,24 @@ class ArticleFragment : BaseFragment() { override fun FragmentArticleBinding.initView() { topAppBar.setNavigationOnClickListener { findNavController().popBackStackWithLifecycle() } + topAppBar.setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_to_search_fragment -> { + findNavController().navigate( + resId = R.id.action_to_search_fragment, + args = Bundle().apply { + putSerializable( + SearchFragment.SEARCH_DOMAIN_KEY, + SearchFragment.SearchDomain.Article(feedUrl), + ) + } + ) + true + } + + else -> false + } + } srlArticleFragment.setOnRefreshListener { intents.trySend(ArticleIntent.Refresh(feedUrl!!)) diff --git a/app/src/main/java/com/skyd/anivu/ui/fragment/download/DownloadViewModel.kt b/app/src/main/java/com/skyd/anivu/ui/fragment/download/DownloadViewModel.kt index cbe64ba2..d84aa2df 100644 --- a/app/src/main/java/com/skyd/anivu/ui/fragment/download/DownloadViewModel.kt +++ b/app/src/main/java/com/skyd/anivu/ui/fragment/download/DownloadViewModel.kt @@ -37,9 +37,9 @@ class DownloadViewModel @Inject constructor( ) .shareWhileSubscribed() .toReadPartialStateChangeFlow() -// .debugLog("DownloadPartialStateChange") + .debugLog("DownloadPartialStateChange") .scan(initialVS) { vs, change -> change.reduce(vs) } -// .debugLog("ViewState") + .debugLog("ViewState") .stateIn( viewModelScope, SharingStarted.Eagerly, diff --git a/app/src/main/java/com/skyd/anivu/ui/fragment/feed/FeedFragment.kt b/app/src/main/java/com/skyd/anivu/ui/fragment/feed/FeedFragment.kt index cb150e38..5c632cce 100644 --- a/app/src/main/java/com/skyd/anivu/ui/fragment/feed/FeedFragment.kt +++ b/app/src/main/java/com/skyd/anivu/ui/fragment/feed/FeedFragment.kt @@ -9,6 +9,7 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope +import androidx.navigation.Navigation import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -26,6 +27,7 @@ import com.skyd.anivu.ui.adapter.variety.AniSpanSize import com.skyd.anivu.ui.adapter.variety.VarietyAdapter import com.skyd.anivu.ui.adapter.variety.proxy.Feed1Proxy import com.skyd.anivu.ui.component.dialog.InputDialogBuilder +import com.skyd.anivu.ui.fragment.search.SearchFragment import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.consumeAsFlow @@ -140,6 +142,26 @@ class FeedFragment : BaseFragment() { } override fun FragmentFeedBinding.initView() { + topAppBar.setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_to_search_fragment -> { + Navigation.findNavController(requireActivity(), R.id.nav_host_fragment_main) + .navigate( + resId = R.id.action_to_search_fragment, + args = Bundle().apply { + putSerializable( + SearchFragment.SEARCH_DOMAIN_KEY, + SearchFragment.SearchDomain.Feed, + ) + } + ) + true + } + + else -> false + } + } + rvFeedFragment.layoutManager = GridLayoutManager( requireContext(), AniSpanSize.MAX_SPAN_SIZE diff --git a/app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchFragment.kt b/app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchFragment.kt new file mode 100644 index 00000000..6d937fe3 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchFragment.kt @@ -0,0 +1,139 @@ +package com.skyd.anivu.ui.fragment.search + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.divider.MaterialDividerItemDecoration +import com.skyd.anivu.base.BaseFragment +import com.skyd.anivu.databinding.FragmentSearchBinding +import com.skyd.anivu.ext.addInsetsByPadding +import com.skyd.anivu.ext.collectIn +import com.skyd.anivu.ext.gone +import com.skyd.anivu.ext.popBackStackWithLifecycle +import com.skyd.anivu.ext.startWith +import com.skyd.anivu.ext.visible +import com.skyd.anivu.ui.adapter.variety.AniSpanSize +import com.skyd.anivu.ui.adapter.variety.VarietyAdapter +import com.skyd.anivu.ui.adapter.variety.proxy.Article1Proxy +import com.skyd.anivu.ui.adapter.variety.proxy.Feed1Proxy +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import java.io.Serializable + + +@AndroidEntryPoint +class SearchFragment : BaseFragment() { + @kotlinx.serialization.Serializable + sealed interface SearchDomain : Serializable { + data object All : SearchDomain + data object Feed : SearchDomain + data class Article(val feedUrl: String?) : SearchDomain + } + + companion object { + const val SEARCH_DOMAIN_KEY = "searchDomain" + } + + private val viewModel by viewModels() + private val searchDomain by lazy { + (arguments?.getSerializable(SEARCH_DOMAIN_KEY) as? SearchDomain) ?: SearchDomain.All + } + private val intents = Channel() + + private val adapter = VarietyAdapter( + mutableListOf(Feed1Proxy(), Article1Proxy()) + ) + + private fun updateState(searchState: SearchState) { + when (val searchResultState = searchState.searchResultState) { + is SearchResultState.Failed -> { + binding.cpiSearchFragment.gone() + adapter.dataList = emptyList() + } + + SearchResultState.Init -> { + binding.cpiSearchFragment.gone() + } + + SearchResultState.Loading -> { + adapter.dataList = emptyList() + binding.cpiSearchFragment.visible() + } + + is SearchResultState.Success -> { + binding.cpiSearchFragment.gone() + adapter.dataList = searchResultState.result + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + intents + .consumeAsFlow() + .startWith(SearchIntent.Init) + .onEach(viewModel::processIntent) + .launchIn(lifecycleScope) + + viewModel.viewState.collectIn(this) { updateState(it) } + } + + override fun FragmentSearchBinding.initView() { + tilSearchFragment.setStartIconOnClickListener { findNavController().popBackStackWithLifecycle() } + tilSearchFragment.editText?.setOnEditorActionListener { v, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + doSearch(v.text.toString()) + true + } else false + } + + rvSearchFragment.layoutManager = GridLayoutManager( + requireContext(), + AniSpanSize.MAX_SPAN_SIZE + ).apply { + spanSizeLookup = AniSpanSize(adapter) + } + + val divider = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL) + divider.isLastItemDecorated = false + rvSearchFragment.addItemDecoration(divider) + rvSearchFragment.adapter = adapter + } + + private fun doSearch(query: String) { + when (val searchDomain = searchDomain) { + SearchDomain.All -> intents.trySend(SearchIntent.SearchAll(query = query)) + SearchDomain.Feed -> intents.trySend(SearchIntent.SearchFeed(query = query)) + is SearchDomain.Article -> { + intents.trySend( + SearchIntent.SearchArticle( + feedUrl = searchDomain.feedUrl, + query = query, + ) + ) + } + } + } + + override fun FragmentSearchBinding.setWindowInsets() { + tilSearchFragment.addInsetsByPadding(top = true, left = true, right = true) +// fabReadFragment.addInsetsByMargin(bottom = true, right = true) +// tvReadFragmentContent.addInsetsByPadding( +// bottom = true, left = true, right = true, hook = ::addFabBottomPaddingHook +// ) + } + + override fun getViewBinding(inflater: LayoutInflater, container: ViewGroup?) = + FragmentSearchBinding.inflate(inflater, container, false) +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchIntent.kt b/app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchIntent.kt new file mode 100644 index 00000000..a6da9e2e --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchIntent.kt @@ -0,0 +1,10 @@ +package com.skyd.anivu.ui.fragment.search + +import com.skyd.anivu.base.mvi.MviIntent + +sealed interface SearchIntent : MviIntent { + data object Init : SearchIntent + data class SearchAll(val query: String) : SearchIntent + data class SearchFeed(val query: String) : SearchIntent + data class SearchArticle(val feedUrl: String? = null, val query: String) : SearchIntent +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchPartialStateChange.kt b/app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchPartialStateChange.kt new file mode 100644 index 00000000..0a8fb6a8 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchPartialStateChange.kt @@ -0,0 +1,28 @@ +package com.skyd.anivu.ui.fragment.search + + +internal sealed interface SearchPartialStateChange { + fun reduce(oldState: SearchState): SearchState + + sealed interface SearchResult : SearchPartialStateChange { + override fun reduce(oldState: SearchState): SearchState { + return when (this) { + is Success -> oldState.copy( + searchResultState = SearchResultState.Success(result = result), + ) + + is Failed -> oldState.copy( + searchResultState = SearchResultState.Failed(msg = msg), + ) + + Loading -> oldState.copy( + searchResultState = SearchResultState.Loading, + ) + } + } + + data class Success(val result: List) : SearchResult + data class Failed(val msg: String) : SearchResult + data object Loading : SearchResult + } +} diff --git a/app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchState.kt b/app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchState.kt new file mode 100644 index 00000000..dcc1b154 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchState.kt @@ -0,0 +1,20 @@ +package com.skyd.anivu.ui.fragment.search + +import com.skyd.anivu.base.mvi.MviViewState + +data class SearchState( + val searchResultState: SearchResultState, +) : MviViewState { + companion object { + fun initial() = SearchState( + searchResultState = SearchResultState.Init, + ) + } +} + +sealed interface SearchResultState { + data class Success(val result: List) : SearchResultState + data object Init : SearchResultState + data object Loading : SearchResultState + data class Failed(val msg: String) : SearchResultState +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchViewModel.kt b/app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchViewModel.kt new file mode 100644 index 00000000..1a402f00 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ui/fragment/search/SearchViewModel.kt @@ -0,0 +1,79 @@ +package com.skyd.anivu.ui.fragment.search + +import androidx.lifecycle.viewModelScope +import com.skyd.anivu.base.mvi.AbstractMviViewModel +import com.skyd.anivu.base.mvi.MviSingleEvent +import com.skyd.anivu.ext.catchMap +import com.skyd.anivu.ext.startWith +import com.skyd.anivu.model.repository.SearchRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val searchRepo: SearchRepository +) : AbstractMviViewModel() { + + override val viewState: StateFlow + + init { + val initialVS = SearchState.initial() + + viewState = merge( + intentSharedFlow.filterIsInstance().take(1), + intentSharedFlow.filterNot { it is SearchIntent.Init } + ) + .shareWhileSubscribed() + .toSearchPartialStateChangeFlow() + .debugLog("SearchPartialStateChange") + .scan(initialVS) { vs, change -> change.reduce(vs) } + .debugLog("ViewState") + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + initialVS + ) + } + + private fun SharedFlow.toSearchPartialStateChangeFlow(): Flow { + return merge( + filterIsInstance().flatMapConcat { + flowOf(emptyList()).map { + SearchPartialStateChange.SearchResult.Success(result = it) + }.startWith(SearchPartialStateChange.SearchResult.Loading) + .catchMap { SearchPartialStateChange.SearchResult.Failed(it.message.toString()) } + }, + filterIsInstance().flatMapConcat { intent -> + searchRepo.requestSearchAll(intent.query).map { + SearchPartialStateChange.SearchResult.Success(result = it) + }.startWith(SearchPartialStateChange.SearchResult.Loading) + .catchMap { SearchPartialStateChange.SearchResult.Failed(it.message.toString()) } + }, + filterIsInstance().flatMapConcat { intent -> + searchRepo.requestSearchFeed(intent.query).map { + SearchPartialStateChange.SearchResult.Success(result = it) + }.startWith(SearchPartialStateChange.SearchResult.Loading) + .catchMap { SearchPartialStateChange.SearchResult.Failed(it.message.toString()) } + }, + filterIsInstance().flatMapConcat { intent -> + searchRepo.requestSearchArticle(intent.feedUrl, intent.query).map { + SearchPartialStateChange.SearchResult.Success(result = it) + }.startWith(SearchPartialStateChange.SearchResult.Loading) + .catchMap { SearchPartialStateChange.SearchResult.Failed(it.message.toString()) } + }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_search_24.xml b/app/src/main/res/drawable/ic_search_24.xml new file mode 100644 index 00000000..7a9578ad --- /dev/null +++ b/app/src/main/res/drawable/ic_search_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml index 01208081..f29df337 100644 --- a/app/src/main/res/layout/fragment_about.xml +++ b/app/src/main/res/layout/fragment_about.xml @@ -9,14 +9,15 @@ + android:layout_height="wrap_content" + app:liftOnScroll="true" + app:liftOnScrollTargetViewId="@+id/nsv_about_fragment"> + android:layout_height="wrap_content" + app:liftOnScroll="true" + app:liftOnScrollTargetViewId="@+id/rv_article_fragment"> + android:layout_height="wrap_content" + app:liftOnScroll="true" + app:liftOnScrollTargetViewId="@+id/rv_download_fragment"> diff --git a/app/src/main/res/layout/fragment_feed.xml b/app/src/main/res/layout/fragment_feed.xml index c196a500..19589156 100644 --- a/app/src/main/res/layout/fragment_feed.xml +++ b/app/src/main/res/layout/fragment_feed.xml @@ -9,13 +9,16 @@ + android:layout_height="wrap_content" + app:liftOnScroll="true" + app:liftOnScrollTargetViewId="@+id/rv_feed_fragment"> diff --git a/app/src/main/res/layout/fragment_license.xml b/app/src/main/res/layout/fragment_license.xml index fe98380d..132f85ed 100644 --- a/app/src/main/res/layout/fragment_license.xml +++ b/app/src/main/res/layout/fragment_license.xml @@ -9,7 +9,9 @@ + android:layout_height="wrap_content" + app:liftOnScroll="true" + app:liftOnScrollTargetViewId="@+id/rv_license_fragment"> + android:layout_height="wrap_content" + app:liftOnScroll="true" + app:liftOnScrollTargetViewId="@+id/rv_media_fragment"> + android:layout_height="wrap_content" + app:liftOnScroll="true" + app:liftOnScrollTargetViewId="@+id/rv_more_fragment"> + android:layout_height="wrap_content" + app:liftOnScroll="true" + app:liftOnScrollTargetViewId="@+id/nsv_read_fragment"> - @@ -36,7 +38,7 @@ android:layout_height="wrap_content" android:padding="16dp" android:textIsSelectable="true" /> - + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_article.xml b/app/src/main/res/menu/menu_article.xml new file mode 100644 index 00000000..94e09af6 --- /dev/null +++ b/app/src/main/res/menu/menu_article.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_feed.xml b/app/src/main/res/menu/menu_feed.xml new file mode 100644 index 00000000..152fc6c5 --- /dev/null +++ b/app/src/main/res/menu/menu_feed.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index afd6903d..819633a7 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -110,4 +110,17 @@ app:exitAnim="@anim/nav_default_exit_anim" app:popEnterAnim="@anim/nav_default_pop_enter_anim" app:popExitAnim="@anim/nav_default_pop_exit_anim" /> + + + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 933b246d..fe2e1c4d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -20,6 +20,7 @@ 编辑 文章 RSS 订阅链接格式错误 + 搜索 返回 详情 附件 @@ -54,4 +55,8 @@ 当您在夜间🌙使用手机时,Night Screen 可以帮助您减少屏幕亮度,减少对眼睛的伤害。 找不到浏览器!网址:%s 开源许可证 + 搜索 + 清除 + 搜索文章 + 搜索订阅 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f7f9a9ea..9b671277 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,6 +20,7 @@ Edit Article Feed URL is illegal + Search Back Read Enclosures @@ -57,4 +58,8 @@ When you use your phone at night 🌙, Night Screen can help you reduce the brightness of the screen and reduce the damage to your eyes. Can\'t find the browser! Link: %s License + Search + Clear + Search articles + Search feeds \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index d2b2271b..7a381e3b 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -9,6 +9,22 @@ ?attr/colorSecondary + + + +