diff --git a/app/schemas/com.saulhdev.feeder.data.db.NeoFeedDb/8.json b/app/schemas/com.saulhdev.feeder.data.db.NeoFeedDb/8.json new file mode 100644 index 00000000..86736504 --- /dev/null +++ b/app/schemas/com.saulhdev.feeder.data.db.NeoFeedDb/8.json @@ -0,0 +1,273 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "15758edc72fe30fa7c4fe31b77f4ebd8", + "entities": [ + { + "tableName": "Feeds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `url` TEXT NOT NULL, `feedImage` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, `alternateId` INTEGER NOT NULL, `fullTextByDefault` INTEGER NOT NULL, `tag` TEXT NOT NULL, `currentlySyncing` INTEGER NOT NULL, `isEnabled` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feedImage", + "columnName": "feedImage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "lastSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alternateId", + "columnName": "alternateId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fullTextByDefault", + "columnName": "fullTextByDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentlySyncing", + "columnName": "currentlySyncing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Feeds_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Feeds_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "Article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `plainTitle` TEXT NOT NULL, `imageUrl` TEXT, `enclosureLink` TEXT, `plainSnippet` TEXT NOT NULL, `description` TEXT NOT NULL, `author` TEXT, `pubDateV2` INTEGER NOT NULL DEFAULT 0, `link` TEXT, `feedId` INTEGER NOT NULL, `firstSyncedTime` INTEGER NOT NULL, `primarySortTime` INTEGER NOT NULL, `categories` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, PRIMARY KEY(`uuid`), FOREIGN KEY(`feedId`) REFERENCES `Feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "guid", + "columnName": "guid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "plainTitle", + "columnName": "plainTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "enclosureLink", + "columnName": "enclosureLink", + "affinity": "TEXT" + }, + { + "fieldPath": "plainSnippet", + "columnName": "plainSnippet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "pubDate", + "columnName": "pubDateV2", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT" + }, + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSyncedTime", + "columnName": "firstSyncedTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primarySortTime", + "columnName": "primarySortTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_Article_feedId_uuid", + "unique": true, + "columnNames": [ + "feedId", + "uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Article_feedId_uuid` ON `${TABLE_NAME}` (`feedId`, `uuid`)" + }, + { + "name": "index_Article_feedId_guid", + "unique": false, + "columnNames": [ + "feedId", + "guid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Article_feedId_guid` ON `${TABLE_NAME}` (`feedId`, `guid`)" + }, + { + "name": "index_Article_uuid_link", + "unique": false, + "columnNames": [ + "uuid", + "link" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Article_uuid_link` ON `${TABLE_NAME}` (`uuid`, `link`)" + }, + { + "name": "index_Article_feedId", + "unique": false, + "columnNames": [ + "feedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Article_feedId` ON `${TABLE_NAME}` (`feedId`)" + } + ], + "foreignKeys": [ + { + "table": "Feeds", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "ArticleIdWithLink", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT Article.uuid, Article.link\n FROM Article\n JOIN feeds f ON Article.feedId = f.id\n WHERE f.fulltextByDefault = 1 OR Article.bookmarked = 1" + } + ], + "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, '15758edc72fe30fa7c4fe31b77f4ebd8')" + ] + } +} diff --git a/app/src/main/java/com/saulhdev/feeder/MainActivity.kt b/app/src/main/java/com/saulhdev/feeder/MainActivity.kt index e659582f..59584e5b 100644 --- a/app/src/main/java/com/saulhdev/feeder/MainActivity.kt +++ b/app/src/main/java/com/saulhdev/feeder/MainActivity.kt @@ -49,10 +49,14 @@ class MainActivity : ComponentActivity() { navController = rememberNavController() TransparentSystemBars() AppTheme( - darkTheme = when (com.saulhdev.feeder.manager.sync.prefs.overlayTheme.getValue()) { - "auto_system" -> isSystemInDarkTheme() - else -> isDarkTheme - }, + darkTheme = + when ( + com.saulhdev.feeder.manager.sync.prefs.overlayTheme + .getValue() + ) { + "auto_system" -> isSystemInDarkTheme() + else -> isDarkTheme + }, dynamicColor = prefs.dynamicColor.getValue(), ) { NavigationManager( @@ -70,14 +74,16 @@ class MainActivity : ComponentActivity() { fun TransparentSystemBars() { DisposableEffect(isDarkTheme, prefs.overlayTheme.getValue()) { enableEdgeToEdge( - statusBarStyle = SystemBarStyle.auto( - android.graphics.Color.TRANSPARENT, - android.graphics.Color.TRANSPARENT, - ) { isDarkTheme }, - navigationBarStyle = SystemBarStyle.auto( - android.graphics.Color.TRANSPARENT, - android.graphics.Color.TRANSPARENT, - ) { isDarkTheme }, + statusBarStyle = + SystemBarStyle.auto( + android.graphics.Color.TRANSPARENT, + android.graphics.Color.TRANSPARENT, + ) { isDarkTheme }, + navigationBarStyle = + SystemBarStyle.auto( + android.graphics.Color.TRANSPARENT, + android.graphics.Color.TRANSPARENT, + ) { isDarkTheme }, ) onDispose {} } @@ -103,6 +109,12 @@ class MainActivity : ComponentActivity() { recreate() } } + prefs.syncFrequency.get().asLiveData().observe(this) { + configurePeriodicSync() + } + prefs.syncOnlyOnWifi.get().asLiveData().observe(this) { + configurePeriodicSync() + } } private fun configurePeriodicSync() { @@ -119,32 +131,36 @@ class MainActivity : ComponentActivity() { } val timeInterval = (prefs.syncFrequency.getValue().toDouble() * 60).toLong() - val workRequestBuilder = PeriodicWorkRequestBuilder( - timeInterval, - TimeUnit.MINUTES, - ) + val workRequestBuilder = + PeriodicWorkRequestBuilder( + timeInterval, + TimeUnit.MINUTES, + ) - val syncWork = workRequestBuilder - .setConstraints(constraints.build()) - .addTag("PeriodicFeedSyncer") - .build() + val syncWork = + workRequestBuilder + .setConstraints(constraints.build()) + .addTag("PeriodicFeedSyncer") + .build() workManager.enqueueUniquePeriodicWork( "feeder_periodic_3", when (replace) { - true -> ExistingPeriodicWorkPolicy.UPDATE + true -> ExistingPeriodicWorkPolicy.UPDATE false -> ExistingPeriodicWorkPolicy.KEEP }, - syncWork + syncWork, ) - } else { workManager.cancelUniqueWork("feeder_periodic_3") } } companion object { - fun navigateIntent(context: Context, destination: String): Intent { + fun navigateIntent( + context: Context, + destination: String, + ): Intent { val uri = "$NAV_BASE$destination".toUri() return Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java) } @@ -152,25 +168,28 @@ class MainActivity : ComponentActivity() { private suspend fun start( activity: Activity, targetIntent: Intent, - extras: Bundle - ): ActivityResult { - return suspendCancellableCoroutine { continuation -> - val intent = Intent(activity, MainActivity::class.java) - .putExtras(extras) - .putExtra("intent", targetIntent) - val resultReceiver = createResultReceiver { - if (continuation.isActive) { - continuation.resume(it) + extras: Bundle, + ): ActivityResult = + suspendCancellableCoroutine { continuation -> + val intent = + Intent(activity, MainActivity::class.java) + .putExtras(extras) + .putExtra("intent", targetIntent) + val resultReceiver = + createResultReceiver { + if (continuation.isActive) { + continuation.resume(it) + } } - } activity.startActivity(intent.putExtra("callback", resultReceiver)) } - } - private fun createResultReceiver(callback: (ActivityResult) -> Unit): ResultReceiver { - return object : ResultReceiver(Handler(Looper.myLooper()!!)) { - - override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { + private fun createResultReceiver(callback: (ActivityResult) -> Unit): ResultReceiver = + object : ResultReceiver(Handler(Looper.myLooper()!!)) { + override fun onReceiveResult( + resultCode: Int, + resultData: Bundle?, + ) { val data = Intent() if (resultData != null) { data.putExtras(resultData) @@ -178,6 +197,5 @@ class MainActivity : ComponentActivity() { callback(ActivityResult(resultCode, data)) } } - } } } \ No newline at end of file diff --git a/app/src/main/java/com/saulhdev/feeder/NeoApp.kt b/app/src/main/java/com/saulhdev/feeder/NeoApp.kt index c1c37338..1fa37d9a 100644 --- a/app/src/main/java/com/saulhdev/feeder/NeoApp.kt +++ b/app/src/main/java/com/saulhdev/feeder/NeoApp.kt @@ -7,6 +7,10 @@ import android.widget.Toast import androidx.lifecycle.SavedStateHandle import androidx.multidex.MultiDexApplication import androidx.work.WorkManager +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.disk.DiskCache +import coil.memory.MemoryCache import com.google.android.material.color.DynamicColors import com.jakewharton.threetenabp.AndroidThreeTen import com.saulhdev.feeder.data.content.FeedPreferences.Companion.prefsModule @@ -26,6 +30,8 @@ import com.saulhdev.feeder.viewmodels.SourceEditViewModel import com.saulhdev.feeder.viewmodels.SourceListViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.Cache +import okhttp3.OkHttpClient import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.androix.startup.KoinStartup @@ -36,9 +42,14 @@ import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.koinConfiguration import org.koin.dsl.module import org.koin.java.KoinJavaComponent.inject +import java.io.File +import java.util.concurrent.TimeUnit @OptIn(KoinExperimentalAPI::class) -class NeoApp : MultiDexApplication(), KoinStartup { +class NeoApp : + MultiDexApplication(), + KoinStartup, + ImageLoaderFactory { val activityHandler = ActivityHandler() private val applicationCoroutineScope = ApplicationCoroutineScope() private val wm: WorkManager by inject(WorkManager::class.java) @@ -58,14 +69,24 @@ class NeoApp : MultiDexApplication(), KoinStartup { } // TODO Move to its class - private val dataModule = module { - single { NeoFeedDb.getInstance(this@NeoApp) } - single { get().feedArticleDao() } - single { get().feedSourceDao() } - singleOf(::ArticleRepository) - singleOf(::SourcesRepository) - singleOf(::SyncRestClient) - } + private val dataModule = + module { + single { + OkHttpClient + .Builder() + .cache(Cache(File(this@NeoApp.cacheDir, "http_cache"), 50L * 1024 * 1024)) + .connectTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + } + single { NeoFeedDb.getInstance(this@NeoApp) } + single { get().feedArticleDao() } + single { get().feedSourceDao() } + singleOf(::ArticleRepository) + singleOf(::SourcesRepository) + singleOf(::SyncRestClient) + } private val coreModule = module { single { contentResolver } @@ -85,6 +106,23 @@ class NeoApp : MultiDexApplication(), KoinStartup { single { this@NeoApp } } + override fun newImageLoader(): ImageLoader = + ImageLoader + .Builder(this) + .memoryCache { + MemoryCache + .Builder(this) + .maxSizePercent(0.25) + .build() + }.diskCache { + DiskCache + .Builder() + .directory(File(cacheDir, "image_cache")) + .maxSizeBytes(100L * 1024 * 1024) + .build() + }.crossfade(true) + .build() + fun onAppStarted() { registerActivityLifecycleCallbacks(activityHandler) } diff --git a/app/src/main/java/com/saulhdev/feeder/data/db/NeoFeedDb.kt b/app/src/main/java/com/saulhdev/feeder/data/db/NeoFeedDb.kt index 3cb33076..cc279af0 100644 --- a/app/src/main/java/com/saulhdev/feeder/data/db/NeoFeedDb.kt +++ b/app/src/main/java/com/saulhdev/feeder/data/db/NeoFeedDb.kt @@ -46,7 +46,7 @@ const val ID_ALL: Long = -1L Feed::class, Article::class, ], - version = 7, + version = 9, exportSchema = true, autoMigrations = [ AutoMigration( @@ -69,6 +69,14 @@ const val ID_ALL: Long = -1L to = 7, spec = NeoFeedDb.RemoveLegacyPubDate::class ), + AutoMigration( + from = 7, + to = 8, + ), + AutoMigration( + from = 8, + to = 9, + ), ], views = [ ArticleIdWithLink::class, diff --git a/app/src/main/java/com/saulhdev/feeder/data/db/dao/FeedArticleDao.kt b/app/src/main/java/com/saulhdev/feeder/data/db/dao/FeedArticleDao.kt index 7642cd50..247dad83 100644 --- a/app/src/main/java/com/saulhdev/feeder/data/db/dao/FeedArticleDao.kt +++ b/app/src/main/java/com/saulhdev/feeder/data/db/dao/FeedArticleDao.kt @@ -57,26 +57,22 @@ interface FeedArticleDao { @Query( """ DELETE FROM Article WHERE uuid IN (:ids) - """ + """, ) suspend fun deleteArticles(ids: List): Int - @Query( - """ - DELETE FROM Article WHERE uuid IN (:ids) - """ - ) - suspend fun deleteFeedArticle(ids: List): Int - @Query( """ DELETE FROM Article WHERE feedId = :feedId - """ + """, ) suspend fun deleteFeedArticle(feedId: Long?): Int @Query("SELECT * FROM Article WHERE guid IS :guid AND feedId IS :feedId") - suspend fun loadArticle(guid: String, feedId: Long?): Article? + suspend fun loadArticle( + guid: String, + feedId: Long?, + ): Article? @Query("SELECT * FROM Article WHERE guid IS :guid") suspend fun loadArticleByGuid(guid: String): Article? @@ -95,7 +91,7 @@ interface FeedArticleDao { SELECT Article.* FROM Article JOIN Feeds ON Article.feedId = Feeds.id WHERE Feeds.isEnabled = 1 - """ + """, ) fun getAllEnabledFeedArticles(): Flow> @@ -120,58 +116,173 @@ interface FeedArticleDao { FROM Article WHERE bookmarked = 1 ORDER BY pinned DESC, pubDateV2 DESC - """ + """, ) fun getAllBookmarked(): Flow> - // Embedded FeedItem + // Embedded FeedItem - sorted queries + @Transaction @Query( """ SELECT Article.* FROM Article JOIN Feeds ON Article.feedId = Feeds.id WHERE Feeds.isEnabled = 1 ORDER BY Article.primarySortTime DESC - """ + """, ) fun getAllEnabledFeedItems(): Flow> + @Transaction + @Query( + """ + SELECT Article.* FROM Article + JOIN Feeds ON Article.feedId = Feeds.id + WHERE Feeds.isEnabled = 1 + ORDER BY Article.primarySortTime ASC + """, + ) + fun getAllEnabledFeedItemsTimeAsc(): Flow> + + @Transaction + @Query( + """ + SELECT Article.* FROM Article + JOIN Feeds ON Article.feedId = Feeds.id + WHERE Feeds.isEnabled = 1 + ORDER BY Article.title ASC + """, + ) + fun getAllEnabledFeedItemsTitleAsc(): Flow> + + @Transaction + @Query( + """ + SELECT Article.* FROM Article + JOIN Feeds ON Article.feedId = Feeds.id + WHERE Feeds.isEnabled = 1 + ORDER BY Article.title DESC + """, + ) + fun getAllEnabledFeedItemsTitleDesc(): Flow> + + @Transaction + @Query( + """ + SELECT Article.* FROM Article + JOIN Feeds ON Article.feedId = Feeds.id + WHERE Feeds.isEnabled = 1 + ORDER BY Feeds.title ASC + """, + ) + fun getAllEnabledFeedItemsSourceAsc(): Flow> + + @Transaction + @Query( + """ + SELECT Article.* FROM Article + JOIN Feeds ON Article.feedId = Feeds.id + WHERE Feeds.isEnabled = 1 + ORDER BY Feeds.title DESC + """, + ) + fun getAllEnabledFeedItemsSourceDesc(): Flow> + + @Transaction @Query( """ SELECT Article.* FROM Article JOIN Feeds ON Article.feedId = Feeds.id WHERE Article.bookmarked = 1 AND Feeds.isEnabled = 1 ORDER BY Article.pinned DESC, Article.pubDateV2 DESC - """ + """, ) fun getAllBookmarkedFeedItems(): Flow> + @Transaction @Query( """ SELECT Article.* FROM Article JOIN Feeds ON Article.feedId = Feeds.id WHERE Feeds.isEnabled = 1 AND Feeds.tag IN (:tags) ORDER BY Article.primarySortTime DESC - """ + """, ) fun getFeedItemsByTagsSimple(tags: Set): Flow> + @Transaction + @Query( + """ + SELECT Article.* FROM Article + JOIN Feeds ON Article.feedId = Feeds.id + WHERE Feeds.isEnabled = 1 AND Feeds.tag IN (:tags) + ORDER BY Article.primarySortTime ASC + """, + ) + fun getFeedItemsByTagsTimeAsc(tags: Set): Flow> + + @Transaction + @Query( + """ + SELECT Article.* FROM Article + JOIN Feeds ON Article.feedId = Feeds.id + WHERE Feeds.isEnabled = 1 AND Feeds.tag IN (:tags) + ORDER BY Article.title ASC + """, + ) + fun getFeedItemsByTagsTitleAsc(tags: Set): Flow> + + @Transaction + @Query( + """ + SELECT Article.* FROM Article + JOIN Feeds ON Article.feedId = Feeds.id + WHERE Feeds.isEnabled = 1 AND Feeds.tag IN (:tags) + ORDER BY Article.title DESC + """, + ) + fun getFeedItemsByTagsTitleDesc(tags: Set): Flow> + + @Transaction + @Query( + """ + SELECT Article.* FROM Article + JOIN Feeds ON Article.feedId = Feeds.id + WHERE Feeds.isEnabled = 1 AND Feeds.tag IN (:tags) + ORDER BY Feeds.title ASC + """, + ) + fun getFeedItemsByTagsSourceAsc(tags: Set): Flow> + + @Transaction + @Query( + """ + SELECT Article.* FROM Article + JOIN Feeds ON Article.feedId = Feeds.id + WHERE Feeds.isEnabled = 1 AND Feeds.tag IN (:tags) + ORDER BY Feeds.title DESC + """, + ) + fun getFeedItemsByTagsSourceDesc(tags: Set): Flow> + + @Transaction @Query( """ SELECT Article.* FROM Article JOIN Feeds ON Article.feedId = Feeds.id WHERE Article.feedId = :feedId AND Feeds.isEnabled = 1 ORDER BY Article.primarySortTime DESC - """ + """, ) fun getFeedItemsForFeed(feedId: Long): Flow> + @Transaction @Query( """ SELECT Article.* FROM Article JOIN Feeds ON Article.feedId = Feeds.id WHERE Article.pinned = 1 AND Feeds.isEnabled = 1 ORDER BY Article.primarySortTime DESC - """ + """, ) fun getPinnedFeedItems(): Flow> @@ -180,38 +291,44 @@ interface FeedArticleDao { @Transaction suspend fun getFeedItemsByTagsComplex(tags: Set): List { - val feedIds = tags.flatMap { tag -> - getEnabledFeedsByTagPattern("%,$tag,%") - }.map { it.id }.distinct() + val feedIds = + tags + .flatMap { tag -> + getEnabledFeedsByTagPattern("%,$tag,%") + }.map { it.id } + .distinct() return getFeedItemsByFeedIds(feedIds) } + @Transaction @Query( """ SELECT Article.* FROM Article JOIN Feeds ON Article.feedId = Feeds.id WHERE Article.feedId IN (:feedIds) AND Feeds.isEnabled = 1 ORDER BY Article.primarySortTime DESC - """ + """, ) suspend fun getFeedItemsByFeedIds(feedIds: List): List @Transaction suspend fun insertOrUpdate( itemsWithText: List>, - block: suspend (Article, String) -> Unit + block: suspend (Article, String) -> Unit, ) { - val (toUpdateItems, toInsertItems) = itemsWithText.partition { (item, _) -> - item.uuid.isNotEmpty() - } + val (toUpdateItems, toInsertItems) = + itemsWithText.partition { (item, _) -> + item.uuid.isNotEmpty() + } updateFeedArticle(toUpdateItems.map { (item, _) -> item }) toUpdateItems.forEach { (item, text) -> block(item, text) } - toInsertItems.map { (item, text) -> item.copy(uuid = UUID.randomUUID().toString()) to text } + toInsertItems + .map { (item, text) -> item.copy(uuid = UUID.randomUUID().toString()) to text } .apply { forEach { (article, text) -> insertFeedArticle(article) diff --git a/app/src/main/java/com/saulhdev/feeder/data/db/models/Article.kt b/app/src/main/java/com/saulhdev/feeder/data/db/models/Article.kt index 1acb3ab5..ccc3331d 100644 --- a/app/src/main/java/com/saulhdev/feeder/data/db/models/Article.kt +++ b/app/src/main/java/com/saulhdev/feeder/data/db/models/Article.kt @@ -43,6 +43,9 @@ import kotlin.time.Instant Index(value = ["feedId", "guid"]), Index(value = ["uuid", "link"]), Index(value = ["feedId"]), + Index(value = ["primarySortTime"]), + Index(value = ["bookmarked"]), + Index(value = ["pinned"]), ], foreignKeys = [ ForeignKey( diff --git a/app/src/main/java/com/saulhdev/feeder/data/db/models/Feed.kt b/app/src/main/java/com/saulhdev/feeder/data/db/models/Feed.kt index 8217f5c1..ba72d84d 100644 --- a/app/src/main/java/com/saulhdev/feeder/data/db/models/Feed.kt +++ b/app/src/main/java/com/saulhdev/feeder/data/db/models/Feed.kt @@ -32,8 +32,7 @@ import kotlin.time.Instant @Entity( tableName = "Feeds", indices = [ - Index(value = ["url"], unique = true), - Index(value = ["id", "url", "title"], unique = true) + Index(value = ["url"], unique = true) ] ) data class Feed( diff --git a/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt b/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt new file mode 100644 index 00000000..eee74743 --- /dev/null +++ b/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt @@ -0,0 +1,606 @@ +/* + * This file is part of Neo Feed + * Copyright (c) 2025 Neo Feed Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.saulhdev.feeder.data.entity + +import androidx.compose.ui.graphics.vector.ImageVector +import com.saulhdev.feeder.ui.icons.Phosphor +import com.saulhdev.feeder.ui.icons.phosphor.Asterisk +import com.saulhdev.feeder.ui.icons.phosphor.Browser +import com.saulhdev.feeder.ui.icons.phosphor.Bug +import com.saulhdev.feeder.ui.icons.phosphor.Car +import com.saulhdev.feeder.ui.icons.phosphor.Copyleft +import com.saulhdev.feeder.ui.icons.phosphor.CurrencyDollar +import com.saulhdev.feeder.ui.icons.phosphor.ForkKnife +import com.saulhdev.feeder.ui.icons.phosphor.GameController +import com.saulhdev.feeder.ui.icons.phosphor.Globe +import com.saulhdev.feeder.ui.icons.phosphor.HeartStraight +import com.saulhdev.feeder.ui.icons.phosphor.Newspaper +import com.saulhdev.feeder.ui.icons.phosphor.PaintRoller +import com.saulhdev.feeder.ui.icons.phosphor.Play +import com.saulhdev.feeder.ui.icons.phosphor.Trophy + +data class SuggestedFeed( + val title: String, + val url: String, + val description: String, +) + +data class SuggestedCategory( + val key: String, + val icon: ImageVector, + val feeds: List, +) + +data class FeedLanguageGroup( + val key: String, + val icon: ImageVector, + val categories: List, +) + +object SuggestedFeedsData { + val groups: List = + listOf( + FeedLanguageGroup( + key = "fr", + icon = Phosphor.Globe, + categories = + listOf( + SuggestedCategory( + key = "news", + icon = Phosphor.Newspaper, + feeds = + listOf( + SuggestedFeed( + title = "Le Monde", + url = "https://www.lemonde.fr/rss/une.xml", + description = "Actualités internationales et françaises", + ), + SuggestedFeed( + title = "Libération", + url = "https://www.liberation.fr/arc/outboundfeeds/rss-all/collection/accueil-702702/", + description = "Actualités, opinions et culture", + ), + SuggestedFeed( + title = "France 24", + url = "https://www.france24.com/fr/rss", + description = "Actualités internationales en français", + ), + SuggestedFeed( + title = "France Info", + url = "https://www.francetvinfo.fr/titres.rss", + description = "Info en continu", + ), + SuggestedFeed( + title = "20 Minutes", + url = "https://www.20minutes.fr/feeds/rss-une.xml", + description = "Actualités en continu", + ), + SuggestedFeed( + title = "Ouest-France", + url = "https://www.ouest-france.fr/rss/une", + description = "Premier quotidien français", + ), + ), + ), + SuggestedCategory( + key = "tech", + icon = Phosphor.Browser, + feeds = + listOf( + SuggestedFeed( + title = "Numerama", + url = "https://www.numerama.com/feed/", + description = "Tech, science et culture numérique", + ), + SuggestedFeed( + title = "Frandroid", + url = "https://www.frandroid.com/feed", + description = "Android, tech et objets connectés", + ), + SuggestedFeed( + title = "Next", + url = "https://next.ink/feed/", + description = "Actualités informatiques et numériques", + ), + SuggestedFeed( + title = "Korben", + url = "https://korben.info/feed", + description = "Tech, hacking et culture geek", + ), + SuggestedFeed( + title = "Clubic", + url = "https://www.clubic.com/feed/news.rss", + description = "High-tech, logiciels et jeux vidéo", + ), + SuggestedFeed( + title = "01net", + url = "https://www.01net.com/feed/", + description = "Actualités high-tech et tests", + ), + SuggestedFeed( + title = "Journal du Geek", + url = "https://www.journaldugeek.com/feed/", + description = "Geek, tech et pop culture", + ), + SuggestedFeed( + title = "PhonAndroid", + url = "https://www.phonandroid.com/feed", + description = "Android, smartphones et bons plans", + ), + ), + ), + SuggestedCategory( + key = "science", + icon = Phosphor.Asterisk, + feeds = + listOf( + SuggestedFeed( + title = "Futura Sciences", + url = "https://www.futura-sciences.com/rss/actualites.xml", + description = "Sciences, environnement et high-tech", + ), + SuggestedFeed( + title = "Cité des Sciences", + url = "https://www.cite-sciences.fr/rss.xml", + description = "Actualités scientifiques", + ), + ), + ), + SuggestedCategory( + key = "lifestyle", + icon = Phosphor.HeartStraight, + feeds = + listOf( + SuggestedFeed( + title = "Madmoizelle", + url = "https://www.madmoizelle.com/feed", + description = "Culture, société et féminisme", + ), + SuggestedFeed( + title = "Cosmopolitan FR", + url = "https://www.cosmopolitan.fr/feed", + description = "Mode, beauté et lifestyle", + ), + SuggestedFeed( + title = "Marie Claire FR", + url = "https://www.marieclaire.fr/feed", + description = "Mode, beauté et culture", + ), + SuggestedFeed( + title = "Elle FR", + url = "https://www.elle.fr/rss", + description = "Mode, beauté et société", + ), + SuggestedFeed( + title = "Grazia FR", + url = "https://www.grazia.fr/feed", + description = "Mode, beauté et célébrités", + ), + SuggestedFeed( + title = "Aufeminin", + url = "https://www.aufeminin.com/rss", + description = "Lifestyle, bien-être et société", + ), + ), + ), + SuggestedCategory( + key = "cooking", + icon = Phosphor.ForkKnife, + feeds = + listOf( + SuggestedFeed( + title = "Marmiton", + url = "https://www.marmiton.org/rss/recettes-du-jour.xml", + description = "Recettes et astuces cuisine", + ), + SuggestedFeed( + title = "750g", + url = "https://www.750g.com/rss.htm", + description = "Recettes de cuisine faciles et gourmandes", + ), + SuggestedFeed( + title = "Cuisine Actuelle", + url = "https://www.cuisineactuelle.fr/feed", + description = "Recettes, menus et conseils cuisine", + ), + ), + ), + SuggestedCategory( + key = "sport", + icon = Phosphor.Trophy, + feeds = + listOf( + SuggestedFeed( + title = "L'Équipe", + url = "https://www.lequipe.fr/rss/actu_rss.xml", + description = "Toute l'actualité sportive", + ), + SuggestedFeed( + title = "RMC Sport", + url = "https://rmcsport.bfmtv.com/rss/fil-sport/", + description = "Sport en direct et analyses", + ), + SuggestedFeed( + title = "Eurosport FR", + url = "https://www.eurosport.fr/rss.xml", + description = "Sport international", + ), + ), + ), + SuggestedCategory( + key = "gaming", + icon = Phosphor.GameController, + feeds = + listOf( + SuggestedFeed( + title = "JV.com", + url = "https://www.jeuxvideo.com/rss/rss.xml", + description = "Jeux vidéo, tests et actualités", + ), + SuggestedFeed( + title = "Gamekult", + url = "https://www.gamekult.com/feed.xml", + description = "Jeux vidéo, tests et previews", + ), + SuggestedFeed( + title = "NoFrag", + url = "https://www.nofrag.com/feed/", + description = "Jeux PC et FPS", + ), + ), + ), + SuggestedCategory( + key = "crypto", + icon = Phosphor.CurrencyDollar, + feeds = + listOf( + SuggestedFeed( + title = "Journal du Coin", + url = "https://journalducoin.com/feed/", + description = "Actualités crypto, Bitcoin et blockchain", + ), + SuggestedFeed( + title = "Cryptoast", + url = "https://cryptoast.fr/feed/", + description = "Guides et actualités crypto en français", + ), + SuggestedFeed( + title = "CoinTribune", + url = "https://www.cointribune.com/feed/", + description = "Actualités blockchain et cryptomonnaies", + ), + SuggestedFeed( + title = "CoinAcademy", + url = "https://coinacademy.fr/feed/", + description = "Éducation et actualités crypto", + ), + SuggestedFeed( + title = "Cointelegraph FR", + url = "https://fr.cointelegraph.com/rss", + description = "Actualités Bitcoin, Ethereum et blockchain", + ), + ), + ), + SuggestedCategory( + key = "auto", + icon = Phosphor.Car, + feeds = + listOf( + SuggestedFeed( + title = "Caradisiac", + url = "https://www.caradisiac.com/rss/", + description = "Auto, essais et actualités", + ), + SuggestedFeed( + title = "Turbo.fr", + url = "https://www.turbo.fr/rss.xml", + description = "Auto, moto et mobilité", + ), + SuggestedFeed( + title = "Automobile Magazine", + url = "https://www.automobile-magazine.fr/feed", + description = "Essais, actualités et nouveautés auto", + ), + ), + ), + ), + ), + FeedLanguageGroup( + key = "en", + icon = Phosphor.Globe, + categories = + listOf( + SuggestedCategory( + key = "tech", + icon = Phosphor.Browser, + feeds = + listOf( + SuggestedFeed( + title = "Ars Technica", + url = "https://feeds.arstechnica.com/arstechnica/index", + description = "Technology, science, and culture news", + ), + SuggestedFeed( + title = "The Verge", + url = "https://www.theverge.com/rss/index.xml", + description = "Technology, science, art, and culture", + ), + SuggestedFeed( + title = "TechCrunch", + url = "https://techcrunch.com/feed/", + description = "Startup and technology news", + ), + SuggestedFeed( + title = "Hacker News", + url = "https://hnrss.org/frontpage", + description = "Social news for programmers and entrepreneurs", + ), + ), + ), + SuggestedCategory( + key = "android", + icon = Phosphor.Bug, + feeds = + listOf( + SuggestedFeed( + title = "Android Police", + url = "https://www.androidpolice.com/feed/", + description = "Android news, reviews, and how-to", + ), + SuggestedFeed( + title = "9to5Google", + url = "https://9to5google.com/feed/", + description = "Google and Android news and analysis", + ), + SuggestedFeed( + title = "XDA Developers", + url = "https://www.xda-developers.com/feed/", + description = "Android, computing, and tech enthusiast community", + ), + ), + ), + SuggestedCategory( + key = "news", + icon = Phosphor.Newspaper, + feeds = + listOf( + SuggestedFeed( + title = "Reuters", + url = "https://www.reutersagency.com/feed/", + description = "International news agency", + ), + SuggestedFeed( + title = "BBC News", + url = "https://feeds.bbci.co.uk/news/rss.xml", + description = "World news from the BBC", + ), + SuggestedFeed( + title = "The Guardian", + url = "https://www.theguardian.com/uk/rss", + description = "UK and international news from The Guardian", + ), + SuggestedFeed( + title = "teleSUR English", + url = "https://www.telesurenglish.net/rss", + description = "Latin American news in English", + ), + SuggestedFeed( + title = "The Tico Times", + url = "https://ticotimes.net/feed/", + description = "Costa Rican news in English", + ), + SuggestedFeed( + title = "Q Costa Rica", + url = "https://qcostarica.com/feed/", + description = "Costa Rican news in English", + ), + SuggestedFeed( + title = "Granma English", + url = "https://en.granma.cu/feed", + description = "Cuban official newspaper in English", + ), + ), + ), + SuggestedCategory( + key = "science", + icon = Phosphor.Asterisk, + feeds = + listOf( + SuggestedFeed( + title = "NASA", + url = "https://www.nasa.gov/feed/", + description = "Space and aeronautics news from NASA", + ), + SuggestedFeed( + title = "Nature", + url = "https://www.nature.com/nature.rss", + description = "International journal of science", + ), + SuggestedFeed( + title = "Science Daily", + url = "https://www.sciencedaily.com/rss/all.xml", + description = "Latest science research news", + ), + ), + ), + SuggestedCategory( + key = "opensource", + icon = Phosphor.Copyleft, + feeds = + listOf( + SuggestedFeed( + title = "It's FOSS", + url = "https://itsfoss.com/feed/", + description = "Linux and open source news and tutorials", + ), + SuggestedFeed( + title = "OMG! Ubuntu", + url = "https://www.omgubuntu.co.uk/feed", + description = "Ubuntu Linux news, apps, and reviews", + ), + SuggestedFeed( + title = "Linux Today", + url = "https://www.linuxtoday.com/feed/", + description = "Linux news and information", + ), + ), + ), + SuggestedCategory( + key = "gaming", + icon = Phosphor.GameController, + feeds = + listOf( + SuggestedFeed( + title = "IGN", + url = "https://feeds.feedburner.com/ign/all", + description = "Video games, movies, TV, and comics", + ), + SuggestedFeed( + title = "Kotaku", + url = "https://kotaku.com/rss", + description = "Gaming news and culture", + ), + SuggestedFeed( + title = "PC Gamer", + url = "https://www.pcgamer.com/rss/", + description = "PC gaming news, reviews and hardware", + ), + ), + ), + SuggestedCategory( + key = "design", + icon = Phosphor.PaintRoller, + feeds = + listOf( + SuggestedFeed( + title = "Smashing Magazine", + url = "https://www.smashingmagazine.com/feed/", + description = "Web design and development", + ), + SuggestedFeed( + title = "A List Apart", + url = "https://alistapart.com/main/feed/", + description = "Web design, development, and content", + ), + SuggestedFeed( + title = "CSS-Tricks", + url = "https://css-tricks.com/feed/", + description = "Tips, tricks, and techniques on CSS", + ), + ), + ), + SuggestedCategory( + key = "finance", + icon = Phosphor.CurrencyDollar, + feeds = + listOf( + SuggestedFeed( + title = "Bloomberg", + url = "https://feeds.bloomberg.com/markets/news.rss", + description = "Financial news and analysis", + ), + SuggestedFeed( + title = "MarketWatch", + url = "https://www.marketwatch.com/rss/topstories", + description = "Stock market and financial news", + ), + ), + ), + ), + ), + FeedLanguageGroup( + key = "es", + icon = Phosphor.Globe, + categories = + listOf( + SuggestedCategory( + key = "news", + icon = Phosphor.Newspaper, + feeds = + listOf( + SuggestedFeed( + title = "La Jornada", + url = "https://www.jornada.com.mx/rss/edicion.xml?v=1", + description = "Noticias de México y el mundo", + ), + SuggestedFeed( + title = "teleSUR", + url = "https://www.telesurtv.net/rss", + description = "Noticias latinoamericanas", + ), + SuggestedFeed( + title = "Granma", + url = "https://www.granma.cu/feed", + description = "Periódico oficial de Cuba", + ), + SuggestedFeed( + title = "La Nación", + url = "https://www.nacion.com/arc/outboundfeeds/rss/", + description = "Noticias de Costa Rica", + ), + SuggestedFeed( + title = "El Ciudadano", + url = "https://www.elciudadano.com/feed", + description = "Noticias de Chile", + ), + SuggestedFeed( + title = "Tiempo Argentino", + url = "https://www.tiempoar.com.ar/rss", + description = "Noticias de Argentina", + ), + ), + ), + ), + ), + FeedLanguageGroup( + key = "youtube", + icon = Phosphor.Play, + categories = + listOf( + SuggestedCategory( + key = "youtube", + icon = Phosphor.Play, + feeds = + listOf( + SuggestedFeed( + title = "AlloCiné Bandes Annonces", + url = "https://www.youtube.com/feeds/videos.xml?channel_id=UC5i9ji_nljs6-mp0jz_hOHg", + description = "Bandes-annonces et extraits de films en français", + ), + SuggestedFeed( + title = "MovieClips Trailers", + url = "https://www.youtube.com/feeds/videos.xml?channel_id=UCi8e0iOVk1fEOogdfu4YgfA", + description = "Latest movie trailers and clips", + ), + SuggestedFeed( + title = "Marques Brownlee (MKBHD)", + url = "https://www.youtube.com/feeds/videos.xml?channel_id=UCBJycsmduvYEL83R_U4JriQ", + description = "Tech reviews and videos", + ), + SuggestedFeed( + title = "Linus Tech Tips", + url = "https://www.youtube.com/feeds/videos.xml?channel_id=UCXuqSBlHAE6Xw-yeJA0Tunw", + description = "Tech tips, reviews, and entertainment", + ), + ), + ), + ), + ), + ) +} diff --git a/app/src/main/java/com/saulhdev/feeder/data/repository/ArticleRepository.kt b/app/src/main/java/com/saulhdev/feeder/data/repository/ArticleRepository.kt index 224d47ec..c25517d0 100644 --- a/app/src/main/java/com/saulhdev/feeder/data/repository/ArticleRepository.kt +++ b/app/src/main/java/com/saulhdev/feeder/data/repository/ArticleRepository.kt @@ -22,6 +22,9 @@ import com.saulhdev.feeder.data.db.NeoFeedDb import com.saulhdev.feeder.data.db.models.Article import com.saulhdev.feeder.data.db.models.ArticleIdWithLink import com.saulhdev.feeder.data.db.models.FeedItem +import com.saulhdev.feeder.data.entity.SORT_CHRONOLOGICAL +import com.saulhdev.feeder.data.entity.SORT_SOURCE +import com.saulhdev.feeder.data.entity.SORT_TITLE import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob @@ -30,35 +33,111 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext @OptIn(ExperimentalCoroutinesApi::class) -class ArticleRepository(db: NeoFeedDb) { +class ArticleRepository( + db: NeoFeedDb, +) { private val cc = Dispatchers.IO private val jcc = Dispatchers.IO + SupervisorJob() private val articlesDao = db.feedArticleDao() + private val feedsDao = db.feedSourceDao() - suspend fun deleteArticles(ids: List) = withContext(jcc) { - articlesDao.deleteArticles(ids) - } + suspend fun deleteArticles(ids: List) = + withContext(jcc) { + articlesDao.deleteArticles(ids) + } - suspend fun getArticleByGuid(guid: String, feedId: Long): Article? { - return withContext(jcc) { + suspend fun getArticleByGuid( + guid: String, + feedId: Long, + ): Article? = + withContext(jcc) { articlesDao.loadArticle(guid = guid, feedId = feedId) } - } fun getArticleById(articleId: String): Flow = - articlesDao.loadArticleById(id = articleId) + articlesDao + .loadArticleById(id = articleId) + .flowOn(cc) + + fun getFeedItemsById(feedId: Long): Flow> = + articlesDao + .getFeedItemsForFeed(feedId) + .flowOn(cc) + + fun getEnabledFeedItems(): Flow> = + articlesDao + .getAllEnabledFeedItems() .flowOn(cc) - fun getEnabledFeedItems(): Flow> = articlesDao.getAllEnabledFeedItems() - .flowOn(cc) + fun getEnabledFeedItemsSorted( + sort: String, + sortAsc: Boolean, + ): Flow> = + when (sort) { + SORT_TITLE -> { + if (sortAsc) { + articlesDao.getAllEnabledFeedItemsTitleAsc() + } else { + articlesDao.getAllEnabledFeedItemsTitleDesc() + } + } + + SORT_SOURCE -> { + if (sortAsc) { + articlesDao.getAllEnabledFeedItemsSourceAsc() + } else { + articlesDao.getAllEnabledFeedItemsSourceDesc() + } + } + + else -> { + if (sortAsc) { + articlesDao.getAllEnabledFeedItemsTimeAsc() + } else { + articlesDao.getAllEnabledFeedItems() + } + } + }.flowOn(cc) fun getFeedItemsByTags(tags: Set): Flow> = - articlesDao.getFeedItemsByTagsSimple(tags) + articlesDao + .getFeedItemsByTagsSimple(tags) .flowOn(cc) + fun getFeedItemsByTagsSorted( + tags: Set, + sort: String, + sortAsc: Boolean, + ): Flow> = + when (sort) { + SORT_TITLE -> { + if (sortAsc) { + articlesDao.getFeedItemsByTagsTitleAsc(tags) + } else { + articlesDao.getFeedItemsByTagsTitleDesc(tags) + } + } + + SORT_SOURCE -> { + if (sortAsc) { + articlesDao.getFeedItemsByTagsSourceAsc(tags) + } else { + articlesDao.getFeedItemsByTagsSourceDesc(tags) + } + } + + else -> { + if (sortAsc) { + articlesDao.getFeedItemsByTagsTimeAsc(tags) + } else { + articlesDao.getFeedItemsByTagsSimple(tags) + } + } + }.flowOn(cc) + suspend fun updateOrInsertArticle( itemsWithText: List>, - block: suspend (Article, String) -> Unit + block: suspend (Article, String) -> Unit, ) = withContext(jcc) { articlesDao.insertOrUpdate(itemsWithText, block) } @@ -81,20 +160,28 @@ class ArticleRepository(db: NeoFeedDb) { } } - suspend fun getItemsToBeCleanedFromFeed(feedId: Long, minKeptPubDate: Long) = withContext(jcc) { + suspend fun getItemsToBeCleanedFromFeed( + feedId: Long, + minKeptPubDate: Long, + ) = withContext(jcc) { articlesDao.getItemsToBeCleanedFromFeed( feedId = feedId, - minKeptPubDate = minKeptPubDate + minKeptPubDate = minKeptPubDate, ) } fun getFeedsItemsWithDefaultFullTextParse(): Flow> = - articlesDao.getArticleIdLinks() + articlesDao + .getArticleIdLinks() .flowOn(cc) - fun getBookmarkedFeedItems(): Flow> = articlesDao.getAllBookmarkedFeedItems() - .flowOn(cc) + fun getBookmarkedFeedItems(): Flow> = + articlesDao + .getAllBookmarkedFeedItems() + .flowOn(cc) - fun getPinnedFeedItems(): Flow> = articlesDao.getPinnedFeedItems() - .flowOn(cc) -} \ No newline at end of file + fun getPinnedFeedItems(): Flow> = + articlesDao + .getPinnedFeedItems() + .flowOn(cc) +} diff --git a/app/src/main/java/com/saulhdev/feeder/manager/models/FeedParser.kt b/app/src/main/java/com/saulhdev/feeder/manager/models/FeedParser.kt index 733c868b..c2610c09 100644 --- a/app/src/main/java/com/saulhdev/feeder/manager/models/FeedParser.kt +++ b/app/src/main/java/com/saulhdev/feeder/manager/models/FeedParser.kt @@ -46,17 +46,13 @@ import java.util.concurrent.TimeUnit private const val YOUTUBE_CHANNEL_ID_ATTR = "data-channel-external-id" -class FeedParser { - private val client = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .writeTimeout(10, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - +class FeedParser( + private val client: OkHttpClient, +) { private val jsonFeedParser: JsonFeedParser = JsonFeedParser() - private suspend fun getFeedIconAtUrl(url: URL): String? { - return try { + private suspend fun getFeedIconAtUrl(url: URL): String? = + try { val html = curl(url) when { html != null -> getFeedIconInHtml(html, baseUrl = url) @@ -66,7 +62,6 @@ class FeedParser { Log.e("FeedParser", "Error when fetching feed icon", t) null } - } private fun getFeedIconInHtml( html: String, @@ -75,19 +70,22 @@ class FeedParser { val doc = Jsoup.parse(html.byteInputStream(), "UTF-8", "") return ( - doc.getElementsByAttributeValue("rel", "apple-touch-icon") + - doc.getElementsByAttributeValue("rel", "icon") + - doc.getElementsByAttributeValue("rel", "shortcut icon") - ) - .filter { it.hasAttr("href") } + doc.getElementsByAttributeValue("rel", "apple-touch-icon") + + doc.getElementsByAttributeValue("rel", "icon") + + doc.getElementsByAttributeValue("rel", "shortcut icon") + ).filter { it.hasAttr("href") } .map { when { - baseUrl != null -> relativeLinkIntoAbsolute( - base = baseUrl, - link = it.attr("href") - ) + baseUrl != null -> { + relativeLinkIntoAbsolute( + base = baseUrl, + link = it.attr("href"), + ) + } - else -> sloppyLinkToStrictURL(it.attr("href")).toString() + else -> { + sloppyLinkToStrictURL(it.attr("href")).toString() + } } }.firstOrNull() } @@ -95,8 +93,8 @@ class FeedParser { /** * Returns all alternate links in the header of an HTML/XML document pointing to feeds. */ - suspend fun getAlternateFeedLinksAtUrl(url: URL): List> { - return try { + suspend fun getAlternateFeedLinksAtUrl(url: URL): List> = + try { val html = curl(url) when { html != null -> getAlternateFeedLinksInHtml(html, baseUrl = url) @@ -106,7 +104,6 @@ class FeedParser { Log.e("FeedParser", "Error when fetching alternate links", t) emptyList() } - } /** * Returns all alternate links in the HTML/XML document pointing to feeds. @@ -117,58 +114,74 @@ class FeedParser { ): List> { val doc = Jsoup.parse(html.byteInputStream(), "UTF-8", "") - val feeds = doc.getElementsByAttributeValue("rel", "alternate") - .filter { element -> - element.hasAttr("href") && element.hasAttr("type") - } - .filter { element -> - val t = element.attr("type").lowercase(Locale.getDefault()) - when { - t.contains("application/atom") -> true - t.contains("application/rss") -> true - // Youtube for example has alternate links with application/json+oembed type. - t == "application/json" -> true - else -> false - } - } - .filter { element -> - val l = element.attr("href").lowercase(Locale.getDefault()) - try { - if (baseUrl != null) { - relativeLinkIntoAbsoluteOrThrow(base = baseUrl, link = l) - } else { - URL(l) + val feeds = + doc + .getElementsByAttributeValue("rel", "alternate") + .filter { element -> + element.hasAttr("href") && element.hasAttr("type") + }.filter { element -> + val t = element.attr("type").lowercase(Locale.getDefault()) + when { + t.contains("application/atom") -> true + + t.contains("application/rss") -> true + + // Youtube for example has alternate links with application/json+oembed type. + t == "application/json" -> true + + else -> false } - true - } catch (_: MalformedURLException) { - false - } - } - .map { - when { - baseUrl != null -> relativeLinkIntoAbsolute( - base = baseUrl, - link = it.attr("href") - ) to it.attr("type") + }.filter { element -> + val l = element.attr("href").lowercase(Locale.getDefault()) + try { + if (baseUrl != null) { + relativeLinkIntoAbsoluteOrThrow(base = baseUrl, link = l) + } else { + URL(l) + } + true + } catch (_: MalformedURLException) { + false + } + }.map { + when { + baseUrl != null -> { + relativeLinkIntoAbsolute( + base = baseUrl, + link = it.attr("href"), + ) to it.attr("type") + } - else -> sloppyLinkToStrictURL(it.attr("href")).toString() to it.attr("type") + else -> { + sloppyLinkToStrictURL(it.attr("href")).toString() to it.attr("type") + } + } } - } return when { - feeds.isNotEmpty() -> feeds - baseUrl?.host == "www.youtube.com" || baseUrl?.host == "youtube.com" -> findFeedLinksForYoutube( - doc - ) + feeds.isNotEmpty() -> { + feeds + } + + baseUrl?.host == "www.youtube.com" || baseUrl?.host == "youtube.com" -> { + findFeedLinksForYoutube( + doc, + ) + } - else -> emptyList() + else -> { + emptyList() + } } } private fun findFeedLinksForYoutube(doc: Document): List> { - val channelId: String? = doc.body().getElementsByAttribute(YOUTUBE_CHANNEL_ID_ATTR) - .firstOrNull() - ?.attr(YOUTUBE_CHANNEL_ID_ATTR) + val channelId: String? = + doc + .body() + .getElementsByAttribute(YOUTUBE_CHANNEL_ID_ATTR) + .firstOrNull() + ?.attr(YOUTUBE_CHANNEL_ID_ATTR) return when (channelId) { null -> emptyList() @@ -184,8 +197,10 @@ class FeedParser { /** * @throws IOException if request fails due to network issue for example */ - private suspend fun curlAndOnResponse(url: URL, block: (suspend (Response) -> Unit)) = - client.curlAndOnResponse(url, block) + private suspend fun curlAndOnResponse( + url: URL, + block: (suspend (Response) -> Unit), + ) = client.curlAndOnResponse(url, block) @Throws(FeedParsingError::class) suspend fun parseFeedUrl(url: URL): JsonFeed? { @@ -202,15 +217,14 @@ class FeedParser { } @Throws(FeedParsingError::class) - suspend fun parseFeedResponse(response: Response): JsonFeed { - return response.body.use { + suspend fun parseFeedResponse(response: Response): JsonFeed = + response.body.use { // OkHttp string method handles BOM and Content-Type header in request parseFeedResponse( response.request.url.toUrl(), it, ) } - } /** * Takes body as bytes to handle encoding correctly @@ -221,10 +235,11 @@ class FeedParser { responseBody: ResponseBody, ): JsonFeed { try { - val feed = when (responseBody.contentType()?.subtype?.contains("json")) { - true -> jsonFeedParser.parseJson(responseBody) - else -> parseRssAtom(url, responseBody) - } + val feed = + when (responseBody.contentType()?.subtype?.contains("json")) { + true -> jsonFeedParser.parseJson(responseBody) + else -> parseRssAtom(url, responseBody) + } return if (feed.feed_url == null) { // Nice to return non-null value here @@ -238,16 +253,19 @@ class FeedParser { } @Throws(FeedParsingError::class) - internal suspend fun parseRssAtom(baseUrl: URL, responseBody: ResponseBody): JsonFeed { + internal suspend fun parseRssAtom( + baseUrl: URL, + responseBody: ResponseBody, + ): JsonFeed { try { responseBody.byteStream().use { bs -> - val feed = XmlReader(bs, true, responseBody.contentType()?.charset()?.name()).use { - SyndFeedInput() - .apply { - isPreserveWireFeed = true - } - .build(it) - } + val feed = + XmlReader(bs, true, responseBody.contentType()?.charset()?.name()).use { + SyndFeedInput() + .apply { + isPreserveWireFeed = true + }.build(it) + } return feed.asFeed(baseUrl = baseUrl) { siteUrl -> getFeedIconAtUrl(siteUrl) } @@ -257,73 +275,84 @@ class FeedParser { } } - class FeedParsingError(val url: URL, e: Throwable) : Exception(e.message, e) + class FeedParsingError( + val url: URL, + e: Throwable, + ) : Exception(e.message, e) } -suspend fun OkHttpClient.getResponse(url: URL, forceNetwork: Boolean = false): Response { - val request = Request.Builder() - .url(url) - .cacheControl( - CacheControl.Builder() - .let { - if (forceNetwork) { - // Force a cache revalidation - it.maxAge(0, TimeUnit.SECONDS) - } else { - // Do a cache revalidation at most every minute - it.maxAge(1, TimeUnit.MINUTES) - } +suspend fun OkHttpClient.getResponse( + url: URL, + forceNetwork: Boolean = false, +): Response { + val request = + Request + .Builder() + .url(url) + .cacheControl( + CacheControl + .Builder() + .let { + if (forceNetwork) { + // Force a cache revalidation + it.maxAge(0, TimeUnit.SECONDS) + } else { + // Do a cache revalidation at most every minute + it.maxAge(1, TimeUnit.MINUTES) + } + }.build(), + ).build() + + val clientToUse = + if (url.userInfo?.isNotBlank() == true) { + val parts = url.userInfo.split(':') + val user = parts.first() + val pass = + if (parts.size > 1) { + parts[1] + } else { + "" } - .build() - ) - .build() - - val clientToUse = if (url.userInfo?.isNotBlank() == true) { - val parts = url.userInfo.split(':') - val user = parts.first() - val pass = if (parts.size > 1) { - parts[1] - } else { - "" - } - val decodedUser = withContext(IO) { - URLDecoder.decode(user, "UTF-8") - } - val decodedPass = withContext(IO) { - URLDecoder.decode(pass, "UTF-8") - } - val credentials = Credentials.basic(decodedUser, decodedPass) - newBuilder() - .authenticator { _, response -> - when { - response.request.header("Authorization") != null -> { - null - } - - else -> { - response.request.newBuilder() - .header("Authorization", credentials) - .build() - } + val decodedUser = + withContext(IO) { + URLDecoder.decode(user, "UTF-8") } - } - .proxyAuthenticator { _, response -> - when { - response.request.header("Proxy-Authorization") != null -> { - null + val decodedPass = + withContext(IO) { + URLDecoder.decode(pass, "UTF-8") + } + val credentials = Credentials.basic(decodedUser, decodedPass) + newBuilder() + .authenticator { _, response -> + when { + response.request.header("Authorization") != null -> { + null + } + + else -> { + response.request + .newBuilder() + .header("Authorization", credentials) + .build() + } } + }.proxyAuthenticator { _, response -> + when { + response.request.header("Proxy-Authorization") != null -> { + null + } - else -> { - response.request.newBuilder() - .header("Proxy-Authorization", credentials) - .build() + else -> { + response.request + .newBuilder() + .header("Proxy-Authorization", credentials) + .build() + } } - } - } - .build() - } else { - this - } + }.build() + } else { + this + } return withContext(IO) { clientToUse.newCall(request).execute() @@ -333,12 +362,15 @@ suspend fun OkHttpClient.getResponse(url: URL, forceNetwork: Boolean = false): R suspend fun OkHttpClient.curl(url: URL): String? { var result: String? = null curlAndOnResponse(url) { - result = it.body?.string() + result = it.body.string() } return result } -suspend fun OkHttpClient.curlAndOnResponse(url: URL, block: (suspend (Response) -> Unit)) { +suspend fun OkHttpClient.curlAndOnResponse( + url: URL, + block: (suspend (Response) -> Unit), +) { val response = getResponse(url) if (!response.isSuccessful) { diff --git a/app/src/main/java/com/saulhdev/feeder/manager/models/FullTextParser.kt b/app/src/main/java/com/saulhdev/feeder/manager/models/FullTextParser.kt index bfe49da1..8bb99d7a 100644 --- a/app/src/main/java/com/saulhdev/feeder/manager/models/FullTextParser.kt +++ b/app/src/main/java/com/saulhdev/feeder/manager/models/FullTextParser.kt @@ -23,46 +23,47 @@ import java.util.concurrent.TimeUnit fun scheduleFullTextParse() { Log.i("FeederFullText", "Scheduling a full text parse work") - val workRequest = OneTimeWorkRequestBuilder() - .addTag("FullTextWorker") - .keepResultsForAtLeast(1, TimeUnit.MINUTES) + val workRequest = + OneTimeWorkRequestBuilder() + .addTag("FullTextWorker") + .keepResultsForAtLeast(1, TimeUnit.MINUTES) val workManager: WorkManager by inject(WorkManager::class.java) workManager.enqueueUniqueWork( "FullTextWorker", ExistingWorkPolicy.REPLACE, - workRequest.build() + workRequest.build(), ) } class FullTextWorker( val context: Context, - workerParams: WorkerParameters + workerParams: WorkerParameters, ) : CoroutineWorker(context, workerParams) { - - private val okHttpClient: OkHttpClient = OkHttpClient.Builder().build() + private val okHttpClient: OkHttpClient by inject(OkHttpClient::class.java) val repository: ArticleRepository by inject(ArticleRepository::class.java) override suspend fun doWork(): Result { Log.i("FeederFullText", "Parsing full texts for articles if missing") val itemsToSync: List = - repository.getFeedsItemsWithDefaultFullTextParse() + repository + .getFeedsItemsWithDefaultFullTextParse() .firstOrNull() ?: return Result.success() - val success: Boolean = itemsToSync - .map { feedItem -> - parseFullArticleIfMissing( - feedItem = feedItem, - okHttpClient = okHttpClient, - filesDir = context.filesDir - ) - } - .fold(true) { acc, value -> - acc && value - } + val success: Boolean = + itemsToSync + .map { feedItem -> + parseFullArticleIfMissing( + feedItem = feedItem, + okHttpClient = okHttpClient, + filesDir = context.filesDir, + ) + }.fold(true) { acc, value -> + acc && value + } return when (success) { - true -> Result.success() + true -> Result.success() false -> Result.failure() } } @@ -71,46 +72,48 @@ class FullTextWorker( suspend fun parseFullArticleIfMissing( feedItem: ArticleIdWithLink, okHttpClient: OkHttpClient, - filesDir: File + filesDir: File, ): Boolean { val fullArticleFile = blobFullFile(itemId = feedItem.uuid, filesDir = filesDir) - return fullArticleFile.isFile || parseFullArticle( - feedItem = feedItem, - okHttpClient = okHttpClient, - filesDir = filesDir - ).first + return fullArticleFile.isFile || + parseFullArticle( + feedItem = feedItem, + okHttpClient = okHttpClient, + filesDir = filesDir, + ).first } suspend fun parseFullArticle( feedItem: ArticleIdWithLink, okHttpClient: OkHttpClient, - filesDir: File -): Pair = withContext(Dispatchers.Default) { - return@withContext try { - val url = feedItem.link ?: return@withContext false to null - Log.d("FeederFullText", "Fetching full page ${feedItem.link}") - val html: String = okHttpClient.curl(URL(url)) ?: return@withContext false to null + filesDir: File, +): Pair = + withContext(Dispatchers.Default) { + return@withContext try { + val url = feedItem.link + Log.d("FeederFullText", "Fetching full page ${feedItem.link}") + val html: String = okHttpClient.curl(URL(url)) ?: return@withContext false to null - // TODO verify encoding is respected in reader - Log.i("FeederFullText", "Parsing article ${feedItem.link}") - val article = Readability4JExtended(url, html).parse() + // TODO verify encoding is respected in reader + Log.i("FeederFullText", "Parsing article ${feedItem.link}") + val article = Readability4JExtended(url, html).parse() - // TODO set image on item if none already - // naiveFindImageLink(article.content)?.let { Parser.unescapeEntities(it, true) } + // TODO set image on item if none already + // naiveFindImageLink(article.content)?.let { Parser.unescapeEntities(it, true) } - Log.d("FeederFullText", "Writing article ${feedItem.link}") - withContext(Dispatchers.IO) { - blobFullOutputStream(feedItem.uuid, filesDir).bufferedWriter().use { writer -> - writer.write(article.contentWithUtf8Encoding) + Log.d("FeederFullText", "Writing article ${feedItem.link}") + withContext(Dispatchers.IO) { + blobFullOutputStream(feedItem.uuid, filesDir).bufferedWriter().use { writer -> + writer.write(article.contentWithUtf8Encoding) + } } + true to null + } catch (e: Throwable) { + Log.e( + "FeederFullText", + "Failed to get fulltext for ${feedItem.link}: ${e.message}", + e, + ) + false to e } - true to null - } catch (e: Throwable) { - Log.e( - "FeederFullText", - "Failed to get fulltext for ${feedItem.link}: ${e.message}", - e - ) - false to e } -} diff --git a/app/src/main/java/com/saulhdev/feeder/manager/service/OverlayView.kt b/app/src/main/java/com/saulhdev/feeder/manager/service/OverlayView.kt index 6e0fed51..ab164b72 100644 --- a/app/src/main/java/com/saulhdev/feeder/manager/service/OverlayView.kt +++ b/app/src/main/java/com/saulhdev/feeder/manager/service/OverlayView.kt @@ -46,9 +46,11 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.koin.java.KoinJavaComponent.inject -class OverlayView(val context: Context) : - OverlayController(context, R.style.AppTheme, R.style.WindowTheme), - KoinComponent, OverlayBridge.OverlayBridgeCallback { +class OverlayView( + val context: Context, +) : OverlayController(context, R.style.AppTheme, R.style.WindowTheme), + KoinComponent, + OverlayBridge.OverlayBridgeCallback { private lateinit var themeHolder: OverlayThemeHolder private val syncScope = CoroutineScope(Dispatchers.IO) + CoroutineName("NeoFeedSync") private val mainScope = CoroutineScope(Dispatchers.Main) @@ -65,16 +67,23 @@ class OverlayView(val context: Context) : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - rootView = View.inflate( - ContextThemeWrapper(this, R.style.AppTheme), - R.layout.overlay_layout, - this.container - ) + rootView = + View.inflate( + ContextThemeWrapper(this, R.style.AppTheme), + R.layout.overlay_layout, + this.container, + ) val mainContainer = rootView.findViewById(R.id.overlay_root) AbstractFloatingView.container = mainContainer AbstractFloatingView.closeAllOpenViews(context) themeHolder = OverlayThemeHolder(this) + setTheme(force = null) + + // Start fully transparent so the overlay never covers the launcher/folders + // while it is closed (for example, before the first onScroll callback arrives). + val bgColor = themeHolder.currentTheme.get(CardTheme.Colors.OVERLAY_BG.ordinal) + getWindow().setBackgroundDrawable(ColorDrawable((bgColor and 0x00ffffff))) initRecyclerView() initHeader() @@ -136,45 +145,51 @@ class OverlayView(val context: Context) : themeHolder.setTheme( when (force ?: prefs.overlayTheme.getValue()) { "auto_system_black" -> CardTheme.getThemeBySystem(context, true) - "auto_system" -> CardTheme.getThemeBySystem(context, false) - "dark" -> CardTheme.defaultDarkThemeColors - "black" -> CardTheme.defaultBlackThemeColors - else -> CardTheme.defaultLightThemeColors - } + "auto_system" -> CardTheme.getThemeBySystem(context, false) + "dark" -> CardTheme.defaultDarkThemeColors + "black" -> CardTheme.defaultBlackThemeColors + else -> CardTheme.defaultLightThemeColors + }, ) setCustomTheme() } private fun updateStubUi() { - val theme = if (themeHolder.currentTheme.get(CardTheme.Colors.OVERLAY_BG.ordinal) - .isDark() - ) CardTheme.defaultDarkThemeColors else CardTheme.defaultLightThemeColors + val theme = + if (themeHolder.currentTheme + .get(CardTheme.Colors.OVERLAY_BG.ordinal) + .isDark() + ) { + CardTheme.defaultDarkThemeColors + } else { + CardTheme.defaultLightThemeColors + } rootView.findViewById(R.id.header_settings).iconTint = ColorStateList.valueOf( theme.get( - CardTheme.Colors.TEXT_COLOR_PRIMARY.ordinal - ) + CardTheme.Colors.TEXT_COLOR_PRIMARY.ordinal, + ), ) rootView.findViewById(R.id.header_filter).iconTint = ColorStateList.valueOf( theme.get( - CardTheme.Colors.TEXT_COLOR_PRIMARY.ordinal - ) + CardTheme.Colors.TEXT_COLOR_PRIMARY.ordinal, + ), ) rootView.findViewById(R.id.header_bookmark).iconTint = ColorStateList.valueOf( theme.get( - CardTheme.Colors.TEXT_COLOR_PRIMARY.ordinal - ) + CardTheme.Colors.TEXT_COLOR_PRIMARY.ordinal, + ), ) - rootView.findViewById(R.id.header_title) + rootView + .findViewById(R.id.header_title) .setTextColor(theme.get(CardTheme.Colors.TEXT_COLOR_PRIMARY.ordinal)) } - private fun initRecyclerView() { val recyclerView = rootView.findViewById(R.id.recycler) val buttonReturnToTop = @@ -183,7 +198,6 @@ class OverlayView(val context: Context) : setOnClickListener { visibility = View.GONE recyclerView.smoothScrollToPosition(0) - } } @@ -198,52 +212,71 @@ class OverlayView(val context: Context) : adapter = this@OverlayView.adapter } - recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - if ((recyclerView.layoutManager as LinearLayoutManager) - .findFirstCompletelyVisibleItemPosition() < 5 - ) { - buttonReturnToTop.visibility = View.GONE - } else if ((recyclerView.layoutManager as LinearLayoutManager) - .findFirstCompletelyVisibleItemPosition() > 5 + recyclerView.addOnScrollListener( + object : RecyclerView.OnScrollListener() { + override fun onScrolled( + recyclerView: RecyclerView, + dx: Int, + dy: Int, ) { - buttonReturnToTop.visibility = View.VISIBLE + super.onScrolled(recyclerView, dx, dy) + if ((recyclerView.layoutManager as LinearLayoutManager) + .findFirstCompletelyVisibleItemPosition() < 5 + ) { + buttonReturnToTop.visibility = View.GONE + } else if ((recyclerView.layoutManager as LinearLayoutManager) + .findFirstCompletelyVisibleItemPosition() > 5 + ) { + buttonReturnToTop.visibility = View.VISIBLE + } } - } - }) + }, + ) } - private fun updateToggleColor(button: MaterialButton, isChecked: Boolean) { + private fun updateToggleColor( + button: MaterialButton, + isChecked: Boolean, + ) { val context = button.context val darkTheme = themeHolder.currentTheme.get(CardTheme.Colors.OVERLAY_BG.ordinal).isDark() val a14 = Android.sdk(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) val a12 = Android.sdk(Build.VERSION_CODES.S) - val backgroundTint = when { - !isChecked -> Color.TRANSPARENT + val backgroundTint = + when { + !isChecked -> { + Color.TRANSPARENT + } - a14 && darkTheme -> ContextCompat.getColor( - context, - android.R.color.system_on_primary_container_dark - ) + a14 && darkTheme -> { + ContextCompat.getColor( + context, + android.R.color.system_on_primary_container_dark, + ) + } - a14 -> ContextCompat.getColor( - context, - android.R.color.system_primary_container_light - ) + a14 -> { + ContextCompat.getColor( + context, + android.R.color.system_primary_container_light, + ) + } - a12 -> ContextCompat.getColor( - context, - android.R.color.system_accent1_400 - ) + a12 -> { + ContextCompat.getColor( + context, + android.R.color.system_accent1_400, + ) + } - else -> ContextCompat.getColor( - context, - R.color.md_theme_primary - ) - } + else -> { + ContextCompat.getColor( + context, + R.color.md_theme_primary, + ) + } + } button.backgroundTintList = ColorStateList.valueOf(backgroundTint) - } private fun initHeader() { @@ -283,7 +316,6 @@ class OverlayView(val context: Context) : } } - rootView.findViewById(R.id.header_settings).apply { setOnClickListener { openMenu(it) @@ -296,18 +328,18 @@ class OverlayView(val context: Context) : popup.show(createMenuList()) { popup.dismiss() when (it.id) { - "config" -> { + "config" -> { mainScope.launch { view.context.safeStartActivity( MainActivity.navigateIntent( view.context, "${Routes.MAIN}/1", - ) + ), ) } } - "reload" -> { + "reload" -> { rootView.findViewById(R.id.recycler).recycledViewPool.clear() refreshNotifications() } @@ -329,8 +361,10 @@ class OverlayView(val context: Context) : super.onScroll(f) val bgColor = themeHolder.currentTheme.get(CardTheme.Colors.OVERLAY_BG.ordinal) - val color = - (prefs.overlayTransparency.getValue() * 255.0f).toInt() shl 24 or (bgColor and 0x00ffffff) + // When the panel is closed (progress 0) the overlay must be fully transparent + // so it does not cover the launcher/folders. + val alpha = if (f <= 0f) 0f else prefs.overlayTransparency.getValue() + val color = (alpha * 255.0f).toInt() shl 24 or (bgColor and 0x00ffffff) getWindow().setBackgroundDrawable(ColorDrawable(color)) } @@ -364,11 +398,10 @@ class OverlayView(val context: Context) : } } - private fun createMenuList(): List { - return listOf( + private fun createMenuList(): List = + listOf( MenuItem(R.drawable.ic_arrow_clockwise, R.string.action_reload, 0, "reload"), MenuItem(R.drawable.ic_gear, R.string.title_settings, 2, "config"), - MenuItem(R.drawable.ic_power, R.string.action_restart, 2, "restart") + MenuItem(R.drawable.ic_power, R.string.action_restart, 2, "restart"), ) - } } diff --git a/app/src/main/java/com/saulhdev/feeder/manager/sync/RssLocalSync.kt b/app/src/main/java/com/saulhdev/feeder/manager/sync/RssLocalSync.kt index 82ff1922..0b9c9d69 100644 --- a/app/src/main/java/com/saulhdev/feeder/manager/sync/RssLocalSync.kt +++ b/app/src/main/java/com/saulhdev/feeder/manager/sync/RssLocalSync.kt @@ -181,11 +181,10 @@ private suspend fun syncFeed( ) { Log.d(TAG, "Fetching ${feedSql.title}") - val okHttpClient = OkHttpClient.Builder() - .build() + val okHttpClient: OkHttpClient by inject(OkHttpClient::class.java) val response: Response = okHttpClient.getResponse(url = feedSql.url, forceNetwork = forceNetwork) - val feedParser = FeedParser() + val feedParser = FeedParser(okHttpClient) val feed: JsonFeed = response.use { response.body.let { responseBody -> when { diff --git a/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Car.kt b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Car.kt new file mode 100644 index 00000000..cec5d6d8 --- /dev/null +++ b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Car.kt @@ -0,0 +1,99 @@ +package com.saulhdev.feeder.ui.icons.phosphor + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import com.saulhdev.feeder.ui.icons.Phosphor + +val Phosphor.Car: ImageVector + get() { + if (_car != null) { + return _car!! + } + _car = + Builder( + name = "Car", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 256.0f, + viewportHeight = 256.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(240.0f, 104.0f) + horizontalLineTo(229.2f) + lineTo(201.42f, 41.5f) + arcTo(16.0f, 16.0f, 0.0f, false, false, 186.8f, 32.0f) + horizontalLineTo(69.2f) + arcToRelative(16.0f, 16.0f, 0.0f, false, false, -14.62f, 9.5f) + lineTo(26.8f, 104.0f) + horizontalLineTo(16.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, 0.0f, 16.0f) + horizontalLineToRelative(8.0f) + verticalLineToRelative(80.0f) + arcToRelative(16.0f, 16.0f, 0.0f, false, false, 16.0f, 16.0f) + horizontalLineTo(64.0f) + arcToRelative(16.0f, 16.0f, 0.0f, false, false, 16.0f, -16.0f) + verticalLineTo(184.0f) + horizontalLineToRelative(96.0f) + verticalLineToRelative(16.0f) + arcToRelative(16.0f, 16.0f, 0.0f, false, false, 16.0f, 16.0f) + horizontalLineToRelative(24.0f) + arcToRelative(16.0f, 16.0f, 0.0f, false, false, 16.0f, -16.0f) + verticalLineTo(120.0f) + horizontalLineToRelative(8.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, 0.0f, -16.0f) + close() + moveTo(69.2f, 48.0f) + horizontalLineTo(186.8f) + lineToRelative(24.89f, 56.0f) + horizontalLineTo(44.31f) + close() + moveTo(64.0f, 200.0f) + horizontalLineTo(40.0f) + verticalLineTo(184.0f) + horizontalLineTo(64.0f) + close() + moveTo(192.0f, 200.0f) + verticalLineTo(184.0f) + horizontalLineToRelative(24.0f) + verticalLineToRelative(16.0f) + close() + moveTo(216.0f, 168.0f) + horizontalLineTo(40.0f) + verticalLineTo(120.0f) + horizontalLineTo(216.0f) + close() + moveTo(56.0f, 144.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, true, 8.0f, -8.0f) + horizontalLineTo(80.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, true, 0.0f, 16.0f) + horizontalLineTo(64.0f) + arcTo(8.0f, 8.0f, 0.0f, false, true, 56.0f, 144.0f) + close() + moveTo(168.0f, 144.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, true, 8.0f, -8.0f) + horizontalLineToRelative(16.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, true, 0.0f, 16.0f) + horizontalLineTo(176.0f) + arcTo(8.0f, 8.0f, 0.0f, false, true, 168.0f, 144.0f) + close() + } + }.build() + return _car!! + } + +private var _car: ImageVector? = null diff --git a/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/CurrencyDollar.kt b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/CurrencyDollar.kt new file mode 100644 index 00000000..1b0a02fd --- /dev/null +++ b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/CurrencyDollar.kt @@ -0,0 +1,78 @@ +package com.saulhdev.feeder.ui.icons.phosphor + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import com.saulhdev.feeder.ui.icons.Phosphor + +val Phosphor.CurrencyDollar: ImageVector + get() { + if (_currencyDollar != null) { + return _currencyDollar!! + } + _currencyDollar = + Builder( + name = "CurrencyDollar", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 256.0f, + viewportHeight = 256.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(152.0f, 120.0f) + horizontalLineTo(136.0f) + verticalLineTo(56.0f) + horizontalLineToRelative(8.0f) + arcToRelative(32.0f, 32.0f, 0.0f, false, true, 32.0f, 32.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, 16.0f, 0.0f) + arcToRelative(48.05f, 48.05f, 0.0f, false, false, -48.0f, -48.0f) + horizontalLineToRelative(-8.0f) + verticalLineTo(24.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, -16.0f, 0.0f) + verticalLineTo(40.0f) + horizontalLineToRelative(-8.0f) + arcToRelative(48.0f, 48.0f, 0.0f, false, false, 0.0f, 96.0f) + horizontalLineToRelative(8.0f) + verticalLineToRelative(64.0f) + horizontalLineTo(104.0f) + arcToRelative(32.0f, 32.0f, 0.0f, false, true, -32.0f, -32.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, -16.0f, 0.0f) + arcToRelative(48.05f, 48.05f, 0.0f, false, false, 48.0f, 48.0f) + horizontalLineToRelative(16.0f) + verticalLineToRelative(16.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, 16.0f, 0.0f) + verticalLineTo(216.0f) + horizontalLineToRelative(16.0f) + arcToRelative(48.0f, 48.0f, 0.0f, false, false, 0.0f, -96.0f) + close() + moveTo(112.0f, 120.0f) + arcToRelative(32.0f, 32.0f, 0.0f, false, true, 0.0f, -64.0f) + horizontalLineToRelative(8.0f) + verticalLineToRelative(64.0f) + close() + moveTo(152.0f, 200.0f) + horizontalLineTo(136.0f) + verticalLineTo(136.0f) + horizontalLineToRelative(16.0f) + arcToRelative(32.0f, 32.0f, 0.0f, false, true, 0.0f, 64.0f) + close() + } + }.build() + return _currencyDollar!! + } + +private var _currencyDollar: ImageVector? = null diff --git a/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/ForkKnife.kt b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/ForkKnife.kt new file mode 100644 index 00000000..659542c1 --- /dev/null +++ b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/ForkKnife.kt @@ -0,0 +1,76 @@ +package com.saulhdev.feeder.ui.icons.phosphor + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import com.saulhdev.feeder.ui.icons.Phosphor + +val Phosphor.ForkKnife: ImageVector + get() { + if (_forkKnife != null) { + return _forkKnife!! + } + _forkKnife = + Builder( + name = "ForkKnife", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 256.0f, + viewportHeight = 256.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(72.0f, 88.0f) + verticalLineTo(40.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, true, 16.0f, 0.0f) + verticalLineTo(88.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, true, -16.0f, 0.0f) + close() + moveTo(216.0f, 40.0f) + verticalLineTo(224.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, true, -16.0f, 0.0f) + verticalLineTo(176.0f) + horizontalLineTo(152.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, true, -8.0f, -8.0f) + arcToRelative(268.75f, 268.75f, 0.0f, false, true, 7.22f, -56.88f) + curveToRelative(9.78f, -40.49f, 28.32f, -67.63f, 53.63f, -78.47f) + arcTo(8.0f, 8.0f, 0.0f, false, true, 216.0f, 40.0f) + close() + moveTo(200.0f, 53.9f) + curveToRelative(-32.17f, 24.57f, -38.47f, 84.42f, -39.7f, 106.1f) + horizontalLineTo(200.0f) + close() + moveTo(119.89f, 38.69f) + arcToRelative(8.0f, 8.0f, 0.0f, true, false, -15.78f, 2.63f) + lineTo(112.0f, 88.63f) + arcToRelative(32.0f, 32.0f, 0.0f, false, true, -64.0f, 0.0f) + lineToRelative(7.88f, -47.31f) + arcToRelative(8.0f, 8.0f, 0.0f, true, false, -15.78f, -2.63f) + lineToRelative(-8.0f, 48.0f) + arcTo(8.17f, 8.17f, 0.0f, false, false, 32.0f, 88.0f) + arcToRelative(48.07f, 48.07f, 0.0f, false, false, 40.0f, 47.32f) + verticalLineTo(224.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, 16.0f, 0.0f) + verticalLineTo(135.32f) + arcTo(48.07f, 48.07f, 0.0f, false, false, 128.0f, 88.0f) + arcToRelative(8.17f, 8.17f, 0.0f, false, false, -0.11f, -1.31f) + close() + } + }.build() + return _forkKnife!! + } + +private var _forkKnife: ImageVector? = null diff --git a/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/GameController.kt b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/GameController.kt new file mode 100644 index 00000000..f12e86d9 --- /dev/null +++ b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/GameController.kt @@ -0,0 +1,98 @@ +package com.saulhdev.feeder.ui.icons.phosphor + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import com.saulhdev.feeder.ui.icons.Phosphor + +val Phosphor.GameController: ImageVector + get() { + if (_gameController != null) { + return _gameController!! + } + _gameController = + Builder( + name = "GameController", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 256.0f, + viewportHeight = 256.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(176.0f, 112.0f) + horizontalLineTo(152.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, true, 0.0f, -16.0f) + horizontalLineToRelative(24.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, true, 0.0f, 16.0f) + close() + moveTo(104.0f, 96.0f) + horizontalLineTo(96.0f) + verticalLineTo(88.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, -16.0f, 0.0f) + verticalLineToRelative(8.0f) + horizontalLineTo(72.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, 0.0f, 16.0f) + horizontalLineToRelative(8.0f) + verticalLineToRelative(8.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, 16.0f, 0.0f) + verticalLineToRelative(-8.0f) + horizontalLineToRelative(8.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, 0.0f, -16.0f) + close() + moveTo(241.48f, 200.65f) + arcToRelative(36.0f, 36.0f, 0.0f, false, true, -54.94f, 4.81f) + curveToRelative(-0.12f, -0.12f, -0.24f, -0.24f, -0.35f, -0.37f) + lineTo(146.48f, 160.0f) + horizontalLineToRelative(-37.0f) + lineTo(69.81f, 205.09f) + lineToRelative(-0.35f, 0.37f) + arcTo(36.08f, 36.08f, 0.0f, false, true, 44.0f, 216.0f) + arcTo(36.0f, 36.0f, 0.0f, false, true, 8.56f, 173.75f) + arcToRelative(0.68f, 0.68f, 0.0f, false, true, 0.0f, -0.14f) + lineTo(24.93f, 89.52f) + arcTo(59.88f, 59.88f, 0.0f, false, true, 83.89f, 40.0f) + horizontalLineTo(172.0f) + arcToRelative(60.08f, 60.08f, 0.0f, false, true, 59.0f, 49.25f) + curveToRelative(0.0f, 0.06f, 0.0f, 0.12f, 0.0f, 0.18f) + lineToRelative(16.37f, 84.17f) + arcToRelative(0.68f, 0.68f, 0.0f, false, true, 0.0f, 0.14f) + arcTo(35.74f, 35.74f, 0.0f, false, true, 241.48f, 200.65f) + close() + moveTo(172.0f, 144.0f) + arcToRelative(44.0f, 44.0f, 0.0f, false, false, 0.0f, -88.0f) + horizontalLineTo(83.89f) + arcTo(43.9f, 43.9f, 0.0f, false, false, 40.68f, 92.37f) + lineToRelative(0.0f, 0.13f) + lineTo(24.3f, 176.59f) + arcTo(20.0f, 20.0f, 0.0f, false, false, 58.0f, 194.3f) + lineToRelative(41.92f, -47.59f) + arcToRelative(8.0f, 8.0f, 0.0f, false, true, 6.0f, -2.71f) + close() + moveTo(231.7f, 176.59f) + lineToRelative(-8.74f, -45.0f) + arcTo(60.0f, 60.0f, 0.0f, false, true, 172.0f, 160.0f) + horizontalLineToRelative(-4.2f) + lineTo(198.0f, 194.31f) + arcToRelative(20.09f, 20.09f, 0.0f, false, false, 17.46f, 5.39f) + arcToRelative(20.0f, 20.0f, 0.0f, false, false, 16.23f, -23.11f) + close() + } + }.build() + return _gameController!! + } + +private var _gameController: ImageVector? = null diff --git a/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Globe.kt b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Globe.kt new file mode 100644 index 00000000..d38578f5 --- /dev/null +++ b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Globe.kt @@ -0,0 +1,95 @@ +package com.saulhdev.feeder.ui.icons.phosphor + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import com.saulhdev.feeder.ui.icons.Phosphor + +val Phosphor.Globe: ImageVector + get() { + if (_globe != null) { + return _globe!! + } + _globe = + Builder( + name = "Globe", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 256.0f, + viewportHeight = 256.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(128.0f, 24.0f) + horizontalLineToRelative(0.0f) + arcTo(104.0f, 104.0f, 0.0f, true, false, 232.0f, 128.0f) + arcTo(104.12f, 104.12f, 0.0f, false, false, 128.0f, 24.0f) + close() + moveTo(216.0f, 128.0f) + arcToRelative(87.61f, 87.61f, 0.0f, false, true, -3.33f, 24.0f) + horizontalLineTo(174.16f) + arcToRelative(157.44f, 157.44f, 0.0f, false, false, 0.0f, -48.0f) + horizontalLineToRelative(38.51f) + arcTo(87.61f, 87.61f, 0.0f, false, true, 216.0f, 128.0f) + close() + moveTo(102.0f, 168.0f) + horizontalLineTo(154.0f) + arcToRelative(115.11f, 115.11f, 0.0f, false, true, -26.0f, 45.0f) + arcTo(115.27f, 115.27f, 0.0f, false, true, 102.0f, 168.0f) + close() + moveTo(98.1f, 152.0f) + arcToRelative(140.84f, 140.84f, 0.0f, false, true, 0.0f, -48.0f) + horizontalLineToRelative(59.88f) + arcToRelative(140.84f, 140.84f, 0.0f, false, true, 0.0f, 48.0f) + close() + moveTo(40.0f, 128.0f) + arcToRelative(87.61f, 87.61f, 0.0f, false, true, 3.33f, -24.0f) + horizontalLineTo(81.84f) + arcToRelative(157.44f, 157.44f, 0.0f, false, false, 0.0f, 48.0f) + horizontalLineTo(43.33f) + arcTo(87.61f, 87.61f, 0.0f, false, true, 40.0f, 128.0f) + close() + moveTo(154.0f, 88.0f) + horizontalLineTo(102.0f) + arcToRelative(115.11f, 115.11f, 0.0f, false, true, 26.0f, -45.0f) + arcTo(115.27f, 115.27f, 0.0f, false, true, 154.0f, 88.0f) + close() + moveTo(206.37f, 88.0f) + horizontalLineTo(170.71f) + arcToRelative(135.28f, 135.28f, 0.0f, false, false, -22.3f, -45.6f) + arcTo(88.29f, 88.29f, 0.0f, false, true, 206.37f, 88.0f) + close() + moveTo(107.59f, 42.4f) + arcTo(135.28f, 135.28f, 0.0f, false, false, 85.29f, 88.0f) + horizontalLineTo(49.63f) + arcTo(88.29f, 88.29f, 0.0f, false, true, 107.59f, 42.4f) + close() + moveTo(49.63f, 168.0f) + horizontalLineTo(85.29f) + arcToRelative(135.28f, 135.28f, 0.0f, false, false, 22.3f, 45.6f) + arcTo(88.29f, 88.29f, 0.0f, false, true, 49.63f, 168.0f) + close() + moveTo(148.41f, 213.6f) + arcToRelative(135.28f, 135.28f, 0.0f, false, false, 22.3f, -45.6f) + horizontalLineToRelative(35.66f) + arcTo(88.29f, 88.29f, 0.0f, false, true, 148.41f, 213.6f) + close() + } + }.build() + return _globe!! + } + +private var _globe: ImageVector? = null diff --git a/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/MagnifyingGlass.kt b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/MagnifyingGlass.kt new file mode 100644 index 00000000..174af171 --- /dev/null +++ b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/MagnifyingGlass.kt @@ -0,0 +1,51 @@ +package com.saulhdev.feeder.ui.icons.phosphor + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import com.saulhdev.feeder.ui.icons.Phosphor + +val Phosphor.MagnifyingGlass: ImageVector + get() { + if (_magnifyingGlass != null) { + return _magnifyingGlass!! + } + _magnifyingGlass = + Builder( + name = "MagnifyingGlass", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 256.0f, + viewportHeight = 256.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(229.66f, 218.34f) + lineToRelative(-50.07f, -50.06f) + arcToRelative(88.11f, 88.11f, 0.0f, true, false, -11.31f, 11.31f) + lineToRelative(50.06f, 50.07f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, 11.32f, -11.32f) + close() + moveTo(40.0f, 112.0f) + arcToRelative(72.0f, 72.0f, 0.0f, true, true, 72.0f, 72.0f) + arcTo(72.08f, 72.08f, 0.0f, false, true, 40.0f, 112.0f) + close() + } + }.build() + return _magnifyingGlass!! + } + +private var _magnifyingGlass: ImageVector? = null diff --git a/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Newspaper.kt b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Newspaper.kt new file mode 100644 index 00000000..67ee1760 --- /dev/null +++ b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Newspaper.kt @@ -0,0 +1,75 @@ +package com.saulhdev.feeder.ui.icons.phosphor + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import com.saulhdev.feeder.ui.icons.Phosphor + +val Phosphor.Newspaper: ImageVector + get() { + if (_newspaper != null) { + return _newspaper!! + } + _newspaper = + Builder( + name = "Newspaper", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 256f, + viewportHeight = 256f, + ).apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(88f, 112f) + arcToRelative(8f, 8f, 0f, false, true, 8f, -8f) + horizontalLineToRelative(80f) + arcToRelative(8f, 8f, 0f, false, true, 0f, 16f) + lineTo(96f, 120f) + arcTo(8f, 8f, 0f, false, true, 88f, 112f) + close() + moveTo(96f, 152f) + horizontalLineToRelative(80f) + arcToRelative(8f, 8f, 0f, false, false, 0f, -16f) + lineTo(96f, 136f) + arcToRelative(8f, 8f, 0f, false, false, 0f, 16f) + close() + moveTo(232f, 64f) + lineTo(232f, 184f) + arcToRelative(24f, 24f, 0f, false, true, -24f, 24f) + lineTo(32f, 208f) + arcTo(24f, 24f, 0f, false, true, 8f, 184.11f) + lineTo(8f, 88f) + arcToRelative(8f, 8f, 0f, false, true, 16f, 0f) + verticalLineToRelative(96f) + arcToRelative(8f, 8f, 0f, false, false, 16f, 0f) + lineTo(40f, 64f) + arcTo(16f, 16f, 0f, false, true, 56f, 48f) + lineTo(216f, 48f) + arcTo(16f, 16f, 0f, false, true, 232f, 64f) + close() + moveTo(216f, 64f) + lineTo(56f, 64f) + lineTo(56f, 184f) + arcToRelative(23.84f, 23.84f, 0f, false, true, -1.37f, 8f) + lineTo(208f, 192f) + arcToRelative(8f, 8f, 0f, false, false, 8f, -8f) + close() + } + }.build() + return _newspaper!! + } + +private var _newspaper: ImageVector? = null diff --git a/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Trophy.kt b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Trophy.kt new file mode 100644 index 00000000..2e472c60 --- /dev/null +++ b/app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Trophy.kt @@ -0,0 +1,87 @@ +package com.saulhdev.feeder.ui.icons.phosphor + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import com.saulhdev.feeder.ui.icons.Phosphor + +val Phosphor.Trophy: ImageVector + get() { + if (_trophy != null) { + return _trophy!! + } + _trophy = + Builder( + name = "Trophy", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 256.0f, + viewportHeight = 256.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(232.0f, 64.0f) + horizontalLineTo(208.0f) + verticalLineTo(48.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, -8.0f, -8.0f) + horizontalLineTo(56.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, -8.0f, 8.0f) + verticalLineTo(64.0f) + horizontalLineTo(24.0f) + arcTo(16.0f, 16.0f, 0.0f, false, false, 8.0f, 80.0f) + verticalLineTo(96.0f) + arcToRelative(40.0f, 40.0f, 0.0f, false, false, 40.0f, 40.0f) + horizontalLineToRelative(3.65f) + arcTo(80.13f, 80.13f, 0.0f, false, false, 120.0f, 191.61f) + verticalLineTo(216.0f) + horizontalLineTo(96.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, 0.0f, 16.0f) + horizontalLineToRelative(64.0f) + arcToRelative(8.0f, 8.0f, 0.0f, false, false, 0.0f, -16.0f) + horizontalLineTo(136.0f) + verticalLineTo(191.58f) + curveToRelative(31.94f, -3.23f, 58.44f, -25.64f, 68.08f, -55.58f) + horizontalLineTo(208.0f) + arcToRelative(40.0f, 40.0f, 0.0f, false, false, 40.0f, -40.0f) + verticalLineTo(80.0f) + arcTo(16.0f, 16.0f, 0.0f, false, false, 232.0f, 64.0f) + close() + moveTo(48.0f, 120.0f) + arcTo(24.0f, 24.0f, 0.0f, false, true, 24.0f, 96.0f) + verticalLineTo(80.0f) + horizontalLineTo(48.0f) + verticalLineToRelative(32.0f) + quadToRelative(0.0f, 4.0f, 0.39f, 8.0f) + close() + moveTo(192.0f, 111.1f) + curveToRelative(0.0f, 35.52f, -29.0f, 64.64f, -64.0f, 64.9f) + arcToRelative(64.0f, 64.0f, 0.0f, false, true, -64.0f, -64.0f) + verticalLineTo(56.0f) + horizontalLineTo(192.0f) + close() + moveTo(232.0f, 96.0f) + arcToRelative(24.0f, 24.0f, 0.0f, false, true, -24.0f, 24.0f) + horizontalLineToRelative(-0.5f) + arcToRelative(81.81f, 81.81f, 0.0f, false, false, 0.5f, -8.9f) + verticalLineTo(80.0f) + horizontalLineToRelative(24.0f) + close() + } + }.build() + return _trophy!! + } + +private var _trophy: ImageVector? = null diff --git a/app/src/main/java/com/saulhdev/feeder/ui/navigation/NavigationManager.kt b/app/src/main/java/com/saulhdev/feeder/ui/navigation/NavigationManager.kt index e9b625da..9c88c96a 100644 --- a/app/src/main/java/com/saulhdev/feeder/ui/navigation/NavigationManager.kt +++ b/app/src/main/java/com/saulhdev/feeder/ui/navigation/NavigationManager.kt @@ -47,6 +47,7 @@ import com.saulhdev.feeder.ui.pages.MainPage import com.saulhdev.feeder.ui.pages.PreferencesPage import com.saulhdev.feeder.ui.pages.SourceAddPage import com.saulhdev.feeder.ui.pages.SourceListPage +import com.saulhdev.feeder.ui.pages.SuggestedFeedsPage import com.saulhdev.feeder.ui.views.ComposeWebView import kotlinx.serialization.Serializable @@ -84,6 +85,7 @@ fun NavigationManager( composable { LicensePage() } composable { ChangelogPage() } composable { SourceAddPage() } + composable { SuggestedFeedsPage() } composable( deepLinks = listOf(navDeepLink { uriPattern = "$NAV_BASE${Routes.WEB_VIEW}/{url}" }) ) { @@ -150,6 +152,9 @@ open class NavRoute { @Serializable data object License : NavRoute() + @Serializable + data object SuggestedFeeds : NavRoute() + @Serializable data class WebView(val url: String = "") : NavRoute() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/saulhdev/feeder/ui/pages/SourceListPage.kt b/app/src/main/java/com/saulhdev/feeder/ui/pages/SourceListPage.kt index af15ddd0..dd058c92 100644 --- a/app/src/main/java/com/saulhdev/feeder/ui/pages/SourceListPage.kt +++ b/app/src/main/java/com/saulhdev/feeder/ui/pages/SourceListPage.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @@ -62,6 +63,7 @@ import com.saulhdev.feeder.ui.icons.phosphor.BookBookmark import com.saulhdev.feeder.ui.icons.phosphor.Bookmarks import com.saulhdev.feeder.ui.icons.phosphor.CloudArrowDown import com.saulhdev.feeder.ui.icons.phosphor.CloudArrowUp +import com.saulhdev.feeder.ui.icons.phosphor.Megaphone import com.saulhdev.feeder.ui.icons.phosphor.Plus import com.saulhdev.feeder.ui.navigation.LocalNavController import com.saulhdev.feeder.ui.navigation.NavRoute @@ -85,63 +87,70 @@ fun SourceListPage( val context = LocalContext.current val navController = LocalNavController.current val scope = rememberCoroutineScope() - val localTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) - .format(FILE_DATETIME_FORMAT) + val localTime = + Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .format(FILE_DATETIME_FORMAT) // TODO reconsider val coroutineScope: ApplicationCoroutineScope by inject(ApplicationCoroutineScope::class.java) val state by viewModel.state.collectAsState() val paneNavigator = rememberListDetailPaneScaffoldNavigator() val sourceId = remember { mutableLongStateOf(-1L) } - val opmlExporter = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument("application/opml") - ) { uri -> - if (uri != null) { - coroutineScope.launch { - context.contentResolver.exportOpml( - uri, - state.tagsSourcesMap - ) + val opmlExporter = + rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/opml"), + ) { uri -> + if (uri != null) { + coroutineScope.launch { + context.contentResolver.exportOpml( + uri, + state.tagsSourcesMap, + ) + } } } - } - val opmlImporter = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocument() - ) { uri -> - if (uri != null) { - coroutineScope.launch { - context.contentResolver.importOpml(uri) + val opmlImporter = + rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri != null) { + coroutineScope.launch { + context.contentResolver.importOpml(uri) + } } } - } - val bookmarksExporter = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument("application/opml") - ) { uri -> - if (uri != null) { - coroutineScope.launch { - context.contentResolver.exportBookmarks( - context, - uri, - state.bookmarked - ) + val bookmarksExporter = + rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/opml"), + ) { uri -> + if (uri != null) { + coroutineScope.launch { + context.contentResolver.exportBookmarks( + context, + uri, + state.bookmarked, + ) + } } } - } - val bookmarksImporter = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocument() - ) { uri -> - if (uri != null) { - coroutineScope.launch { - context.contentResolver.importBookmarks( - context, - uri, - ) + val bookmarksImporter = + rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri != null) { + coroutineScope.launch { + context.contentResolver.importBookmarks( + context, + uri, + ) + } } } - } NavigableListDetailPaneScaffold( navigator = paneNavigator, @@ -156,7 +165,7 @@ fun SourceListPage( navController.navigate(NavRoute.SourceAdd) }, modifier = Modifier.padding(16.dp), - shape = MaterialTheme.shapes.extraLarge + shape = MaterialTheme.shapes.extraLarge, ) { Icon( imageVector = Phosphor.Plus, @@ -165,6 +174,16 @@ fun SourceListPage( } }, actions = { + IconButton( + onClick = { + navController.navigate(NavRoute.SuggestedFeeds) + }, + ) { + Icon( + imageVector = Phosphor.Megaphone, + contentDescription = stringResource(id = R.string.suggested_feeds), + ) + } OverflowMenu { DropdownMenuItem( leadingIcon = { @@ -180,11 +199,11 @@ fun SourceListPage( "text/plain", "text/xml", "text/opml", - "*/*" - ) + "*/*", + ), ) }, - text = { Text(text = stringResource(id = R.string.sources_import_opml)) } + text = { Text(text = stringResource(id = R.string.sources_import_opml)) }, ) DropdownMenuItem( leadingIcon = { @@ -195,9 +214,9 @@ fun SourceListPage( }, onClick = { hideMenu() - opmlExporter.launch("NF-${localTime}.opml") + opmlExporter.launch("NF-$localTime.opml") }, - text = { Text(text = stringResource(id = R.string.sources_export_opml)) } + text = { Text(text = stringResource(id = R.string.sources_export_opml)) }, ) DropdownMenuItem( leadingIcon = { @@ -213,11 +232,11 @@ fun SourceListPage( "text/plain", "text/xml", "text/bkm", - "*/*" - ) + "*/*", + ), ) }, - text = { Text(text = stringResource(id = R.string.sources_import_bookmarks)) } + text = { Text(text = stringResource(id = R.string.sources_import_bookmarks)) }, ) DropdownMenuItem( leadingIcon = { @@ -228,21 +247,23 @@ fun SourceListPage( }, onClick = { hideMenu() - bookmarksExporter.launch("NF-${localTime}.bkm") + bookmarksExporter.launch("NF-$localTime.bkm") }, - text = { Text(text = stringResource(id = R.string.sources_export_bookmarks)) } + text = { Text(text = stringResource(id = R.string.sources_export_bookmarks)) }, ) } - } + }, ) { paddingValues -> LazyColumn( - modifier = Modifier - .fillMaxSize(), - contentPadding = PaddingValues( - start = 8.dp, - end = 8.dp, - top = paddingValues.calculateTopPadding() - ), + modifier = + Modifier + .fillMaxSize(), + contentPadding = + PaddingValues( + start = 8.dp, + end = 8.dp, + top = paddingValues.calculateTopPadding(), + ), verticalArrangement = Arrangement.spacedBy(8.dp), ) { item { @@ -256,16 +277,16 @@ fun SourceListPage( scope.launch { paneNavigator.navigateTo( ListDetailPaneScaffoldRole.Detail, - item.id + item.id, ) } }, onSwitch = { viewModel.updateFeed( it.copy(isEnabled = false), - false + false, ) - } + }, ) } item { @@ -279,7 +300,7 @@ fun SourceListPage( scope.launch { paneNavigator.navigateTo( ListDetailPaneScaffoldRole.Detail, - item.id + item.id, ) } }, @@ -288,7 +309,7 @@ fun SourceListPage( it.copy(isEnabled = true), true, ) - } + }, ) } item { @@ -299,9 +320,12 @@ fun SourceListPage( } }, detailPane = { - sourceId.longValue = paneNavigator.currentDestination - ?.takeIf { it.pane == this.paneRole }?.contentKey - .toString().toLongOrDefault(-1L) + sourceId.longValue = + paneNavigator.currentDestination + ?.takeIf { it.pane == this.paneRole } + ?.contentKey + .toString() + .toLongOrDefault(-1L) sourceId.longValue.takeIf { it != -1L }?.let { id -> AnimatedPane { @@ -312,6 +336,6 @@ fun SourceListPage( } } } - } + }, ) } diff --git a/app/src/main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt b/app/src/main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt new file mode 100644 index 00000000..9ab7cb3d --- /dev/null +++ b/app/src/main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt @@ -0,0 +1,380 @@ +/* + * This file is part of Neo Feed + * Copyright (c) 2025 Neo Feed Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.saulhdev.feeder.ui.pages + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.saulhdev.feeder.R +import com.saulhdev.feeder.data.db.models.Feed +import com.saulhdev.feeder.data.entity.SuggestedCategory +import com.saulhdev.feeder.data.entity.SuggestedFeed +import com.saulhdev.feeder.data.entity.SuggestedFeedsData +import com.saulhdev.feeder.ui.components.ViewWithActionBar +import com.saulhdev.feeder.ui.icons.Phosphor +import com.saulhdev.feeder.ui.icons.phosphor.CaretDown +import com.saulhdev.feeder.ui.icons.phosphor.CheckCircle +import com.saulhdev.feeder.ui.icons.phosphor.FunnelSimple +import com.saulhdev.feeder.ui.icons.phosphor.Plus +import com.saulhdev.feeder.utils.extensions.koinNeoViewModel +import com.saulhdev.feeder.utils.sloppyLinkToStrictURL +import com.saulhdev.feeder.viewmodels.SourceListViewModel + +private val languageStringRes = + mapOf( + "fr" to R.string.lang_fr, + "en" to R.string.lang_en, + "es" to R.string.lang_es, + "youtube" to R.string.lang_youtube, + ) + +private val categoryStringRes = + mapOf( + "news" to R.string.cat_news, + "tech" to R.string.cat_tech, + "science" to R.string.cat_science, + "lifestyle" to R.string.cat_lifestyle, + "cooking" to R.string.cat_cooking, + "sport" to R.string.cat_sport, + "gaming" to R.string.cat_gaming, + "crypto" to R.string.cat_crypto, + "auto" to R.string.cat_auto, + "android" to R.string.cat_android, + "opensource" to R.string.cat_opensource, + "design" to R.string.cat_design, + "finance" to R.string.cat_finance, + "youtube" to R.string.cat_youtube, + ) + +@Composable +fun SuggestedFeedsPage( + viewModel: SourceListViewModel = koinNeoViewModel(), +) { + val state by viewModel.state.collectAsState() + val existingUrlMap = state.allSources.associate { it.url.toString() to it.id } + var searchQuery by remember { mutableStateOf("") } + val collapsedGroups = remember { mutableStateMapOf() } + val collapsedCategories = remember { mutableStateMapOf() } + + ViewWithActionBar( + title = stringResource(R.string.suggested_feeds), + showBackButton = true, + ) { paddingValues -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + start = 8.dp, + end = 8.dp, + top = paddingValues.calculateTopPadding(), + bottom = 8.dp, + ), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + item(key = "search") { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + placeholder = { Text(stringResource(R.string.search_feeds)) }, + leadingIcon = { + Icon( + imageVector = Phosphor.FunnelSimple, + contentDescription = null, + ) + }, + singleLine = true, + shape = MaterialTheme.shapes.large, + ) + } + + SuggestedFeedsData.groups.forEach { group -> + val langResId = languageStringRes[group.key] ?: R.string.lang_en + val isGroupCollapsed = collapsedGroups[group.key] == true + val isSearching = searchQuery.isNotBlank() + + item(key = "lang_${group.key}") { + val langName = stringResource(langResId) + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + collapsedGroups[group.key] = !isGroupCollapsed + }.padding(top = 12.dp, bottom = 4.dp, start = 8.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = group.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Text( + text = langName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f).padding(start = 8.dp), + ) + Icon( + imageVector = Phosphor.CaretDown, + contentDescription = null, + modifier = + Modifier + .size(20.dp) + .rotate(if (isGroupCollapsed && !isSearching) -90f else 0f), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (!isGroupCollapsed || isSearching) { + group.categories.forEach { category -> + val catKey = "${group.key}_${category.key}" + val catResId = categoryStringRes[category.key] ?: R.string.cat_news + val isCatCollapsed = collapsedCategories[catKey] == true + + val filteredFeeds = + if (isSearching) { + category.feeds.filter { + it.title.contains(searchQuery, ignoreCase = true) || + it.description.contains(searchQuery, ignoreCase = true) + } + } else { + category.feeds + } + + if (filteredFeeds.isNotEmpty()) { + item(key = "header_$catKey") { + val catName = stringResource(catResId) + CategoryHeader( + category = category, + catName = catName, + isCollapsed = isCatCollapsed && !isSearching, + addedCount = filteredFeeds.count { existingUrlMap.containsKey(it.url) }, + totalCount = filteredFeeds.size, + onToggle = { + collapsedCategories[catKey] = !isCatCollapsed + }, + onAddAll = { + filteredFeeds + .filter { !existingUrlMap.containsKey(it.url) } + .forEach { sf -> + viewModel.insertFeed( + Feed( + title = sf.title, + url = sloppyLinkToStrictURL(sf.url), + description = sf.description, + tag = category.key, + ), + ) + } + }, + onRemoveAll = { + filteredFeeds + .mapNotNull { existingUrlMap[it.url] } + .forEach { id -> viewModel.deleteFeed(id) } + }, + allAdded = + filteredFeeds.all { existingUrlMap.containsKey(it.url) }, + ) + } + + if ((!isCatCollapsed || isSearching)) { + items( + filteredFeeds, + key = { "${group.key}_${it.url}" }, + ) { suggestedFeed -> + val feedId = existingUrlMap[suggestedFeed.url] + AnimatedVisibility( + visible = true, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + SuggestedFeedItem( + feed = suggestedFeed, + isAdded = feedId != null, + onAdd = { + viewModel.insertFeed( + Feed( + title = suggestedFeed.title, + url = + sloppyLinkToStrictURL(suggestedFeed.url), + description = suggestedFeed.description, + tag = category.key, + ), + ) + }, + onRemove = { + feedId?.let { viewModel.deleteFeed(it) } + }, + ) + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun CategoryHeader( + category: SuggestedCategory, + catName: String, + isCollapsed: Boolean, + addedCount: Int, + totalCount: Int, + onToggle: () -> Unit, + onAddAll: () -> Unit, + onRemoveAll: () -> Unit, + allAdded: Boolean, +) { + Column { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = category.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Text( + text = catName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + Text( + text = "($addedCount/$totalCount)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(start = 4.dp), + ) + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = if (allAdded) onRemoveAll else onAddAll, + ) { + Text( + text = + stringResource( + if (allAdded) R.string.remove_all else R.string.add_all, + ), + style = MaterialTheme.typography.labelSmall, + ) + } + Icon( + imageVector = Phosphor.CaretDown, + contentDescription = null, + modifier = + Modifier + .size(16.dp) + .rotate(if (isCollapsed) -90f else 0f), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun SuggestedFeedItem( + feed: SuggestedFeed, + isAdded: Boolean, + onAdd: () -> Unit, + onRemove: () -> Unit, +) { + ListItem( + modifier = Modifier.clickable(onClick = if (isAdded) onRemove else onAdd), + headlineContent = { + Text(text = feed.title) + }, + supportingContent = { + Text(text = feed.description) + }, + trailingContent = { + if (isAdded) { + IconButton(onClick = onRemove) { + Icon( + imageVector = Phosphor.CheckCircle, + contentDescription = stringResource(R.string.feed_already_added), + tint = MaterialTheme.colorScheme.primary, + ) + } + } else { + IconButton(onClick = onAdd) { + Icon( + imageVector = Phosphor.Plus, + contentDescription = stringResource(R.string.suggested_feeds), + ) + } + } + }, + ) +} diff --git a/app/src/main/java/com/saulhdev/feeder/ui/theme/CardTheme.kt b/app/src/main/java/com/saulhdev/feeder/ui/theme/CardTheme.kt index 4ffc1cf1..7e6331f8 100644 --- a/app/src/main/java/com/saulhdev/feeder/ui/theme/CardTheme.kt +++ b/app/src/main/java/com/saulhdev/feeder/ui/theme/CardTheme.kt @@ -30,131 +30,133 @@ object CardTheme { val defaultDarkThemeColors = createDarkTheme() val defaultBlackThemeColors = createBlackTheme() - private fun createLightTheme(): SparseIntArray { - return SparseIntArray().apply { + private fun createLightTheme(): SparseIntArray = + SparseIntArray().apply { addBasicThings(this) put( Colors.CARD_BG.ordinal, ContextCompat.getColor( NeoApp.instance!!, - com.google.android.material.R.color.m3_sys_color_dynamic_light_surface_container - ) + com.google.android.material.R.color.m3_sys_color_dynamic_light_surface_container, + ), ) put( Colors.TEXT_COLOR_PRIMARY.ordinal, ContextCompat.getColor( NeoApp.instance!!, - com.google.android.material.R.color.m3_sys_color_dynamic_light_on_surface - ) + com.google.android.material.R.color.m3_sys_color_dynamic_light_on_surface, + ), ) put( Colors.TEXT_COLOR_SECONDARY.ordinal, ContextCompat.getColor( NeoApp.instance!!, - com.google.android.material.R.color.m3_sys_color_dynamic_light_on_surface_variant - ) + com.google.android.material.R.color.m3_sys_color_dynamic_light_on_surface_variant, + ), ) put( Colors.OVERLAY_BG.ordinal, ContextCompat.getColor( NeoApp.instance!!, - com.google.android.material.R.color.m3_sys_color_dynamic_light_background - ) + com.google.android.material.R.color.m3_sys_color_dynamic_light_background, + ), ) put(Colors.IS_LIGHT.ordinal, 1) } - } - private fun createDarkTheme(): SparseIntArray { - return SparseIntArray().apply { + private fun createDarkTheme(): SparseIntArray = + SparseIntArray().apply { addBasicThings(this) put( Colors.CARD_BG.ordinal, ContextCompat.getColor( NeoApp.instance!!, - com.google.android.material.R.color.m3_sys_color_dynamic_dark_surface_container - ) + com.google.android.material.R.color.m3_sys_color_dynamic_dark_surface_container, + ), ) put( Colors.TEXT_COLOR_PRIMARY.ordinal, ContextCompat.getColor( NeoApp.instance!!, - com.google.android.material.R.color.m3_sys_color_dynamic_dark_on_surface - ) + com.google.android.material.R.color.m3_sys_color_dynamic_dark_on_surface, + ), ) put( Colors.TEXT_COLOR_SECONDARY.ordinal, ContextCompat.getColor( NeoApp.instance!!, - com.google.android.material.R.color.m3_sys_color_dynamic_dark_on_surface_variant - ) + com.google.android.material.R.color.m3_sys_color_dynamic_dark_on_surface_variant, + ), ) put( Colors.OVERLAY_BG.ordinal, ContextCompat.getColor( NeoApp.instance!!, - com.google.android.material.R.color.m3_sys_color_dynamic_dark_background - ) + com.google.android.material.R.color.m3_sys_color_dynamic_dark_background, + ), ) put(Colors.IS_LIGHT.ordinal, 0) } - } - private fun createBlackTheme(): SparseIntArray { - return SparseIntArray().apply { + private fun createBlackTheme(): SparseIntArray = + SparseIntArray().apply { addBasicThings(this) put( Colors.CARD_BG.ordinal, ContextCompat.getColor( NeoApp.instance!!, - com.google.android.material.R.color.m3_sys_color_dynamic_dark_surface_container - ) + android.R.color.black, + ), ) put( Colors.TEXT_COLOR_PRIMARY.ordinal, ContextCompat.getColor( NeoApp.instance!!, - com.google.android.material.R.color.m3_sys_color_dynamic_dark_on_surface - ) + com.google.android.material.R.color.m3_sys_color_dynamic_dark_on_surface, + ), ) put( Colors.TEXT_COLOR_SECONDARY.ordinal, ContextCompat.getColor( NeoApp.instance!!, - com.google.android.material.R.color.m3_sys_color_dynamic_dark_on_surface_variant - ) + com.google.android.material.R.color.m3_sys_color_dynamic_dark_on_surface_variant, + ), ) put( Colors.OVERLAY_BG.ordinal, ContextCompat.getColor( NeoApp.instance!!, - android.R.color.black - ) + android.R.color.black, + ), ) put(Colors.IS_LIGHT.ordinal, 0) } - } - private fun addBasicThings(array: SparseIntArray): SparseIntArray { - return array.apply { + private fun addBasicThings(array: SparseIntArray): SparseIntArray = + array.apply { put( Colors.ACCENT_COLOR.ordinal, - ContextCompat.getColor(NeoApp.instance!!, R.color.globalAccent) + ContextCompat.getColor(NeoApp.instance!!, R.color.globalAccent), ) } - } - fun getThemeBySystem(ctx: Context, black: Boolean): SparseIntArray { - return when { - Configuration.UI_MODE_NIGHT_YES == ctx.resources.configuration.uiMode.and(Configuration.UI_MODE_NIGHT_MASK) && black - -> createBlackTheme() + fun getThemeBySystem( + ctx: Context, + black: Boolean, + ): SparseIntArray = + when { + Configuration.UI_MODE_NIGHT_YES == + ctx.resources.configuration.uiMode + .and(Configuration.UI_MODE_NIGHT_MASK) && black + -> createBlackTheme() - Configuration.UI_MODE_NIGHT_YES == ctx.resources.configuration.uiMode.and(Configuration.UI_MODE_NIGHT_MASK) - -> createDarkTheme() + Configuration.UI_MODE_NIGHT_YES == + ctx.resources.configuration.uiMode + .and(Configuration.UI_MODE_NIGHT_MASK) + -> createDarkTheme() else -> createLightTheme() } - } enum class Colors { CARD_BG, @@ -162,6 +164,6 @@ object CardTheme { TEXT_COLOR_SECONDARY, ACCENT_COLOR, OVERLAY_BG, - IS_LIGHT + IS_LIGHT, } } \ No newline at end of file diff --git a/app/src/main/java/com/saulhdev/feeder/ui/views/BaseBottomSheet.kt b/app/src/main/java/com/saulhdev/feeder/ui/views/BaseBottomSheet.kt index 247a813c..5691af6b 100644 --- a/app/src/main/java/com/saulhdev/feeder/ui/views/BaseBottomSheet.kt +++ b/app/src/main/java/com/saulhdev/feeder/ui/views/BaseBottomSheet.kt @@ -37,7 +37,7 @@ class BaseBottomSheet @JvmOverloads constructor( } override fun getScrimColor(context: Context): Int { - return ContextCompat.getColor(context, R.color.botttom_sheet_scrim) + return ContextCompat.getColor(context, R.color.bottom_sheet_scrim) } diff --git a/app/src/main/java/com/saulhdev/feeder/utils/FeederUtils.kt b/app/src/main/java/com/saulhdev/feeder/utils/FeederUtils.kt index b6cb94e9..1f85db38 100644 --- a/app/src/main/java/com/saulhdev/feeder/utils/FeederUtils.kt +++ b/app/src/main/java/com/saulhdev/feeder/utils/FeederUtils.kt @@ -35,33 +35,30 @@ import java.net.URLDecoder import java.net.URLEncoder import java.util.Locale -fun getThemes(context: Context): Map { - return mapOf( +fun getThemes(context: Context): Map = + mapOf( "auto_system" to context.resources.getString(R.string.theme_auto_system), "auto_system_black" to context.resources.getString(R.string.theme_auto_system_black), "light" to context.resources.getString(R.string.theme_light), "dark" to context.resources.getString(R.string.theme_dark), "black" to context.resources.getString(R.string.theme_black), ) -} -fun getSortingOptions(context: Context): Map { - return mapOf( +fun getSortingOptions(context: Context): Map = + mapOf( SORT_CHRONOLOGICAL to context.resources.getString(R.string.sorting_chronological), SORT_TITLE to context.resources.getString(R.string.sorting_title), SORT_SOURCE to context.resources.getString(R.string.sorting_source), ) -} -fun getSyncFrequency(context: Context): Map { - return mapOf( +fun getSyncFrequency(context: Context): Map = + mapOf( "0.5" to context.resources.getString(R.string.sync_half_hour_minutes), "1" to context.resources.getString(R.string.sync_one_hour), "2" to context.resources.getString(R.string.sync_two_hours), "3" to context.resources.getString(R.string.sync_three_hours), - "6" to context.resources.getString(R.string.sync_six_hours) + "6" to context.resources.getString(R.string.sync_six_hours), ) -} fun getSyncRange(context: Context): Map { return mapOf( @@ -94,68 +91,81 @@ fun getItemsPerFeed(): Map { ) } -fun getBackgroundOptions(context: Context): Map { - return mapOf( +fun getBackgroundOptions(context: Context): Map = + mapOf( "theme" to context.resources.getString(R.string.background_theme_option), "light" to context.resources.getString(R.string.theme_light), - "dark" to context.resources.getString(R.string.theme_dark) + "dark" to context.resources.getString(R.string.theme_dark), ) -} /** * Ensures a url is valid, having a scheme and everything. It turns 'google.com' into 'http://google.com' for example. */ -fun sloppyLinkToStrictURL(url: String): URL = try { - // If no exception, it's valid - URL(url) -} catch (_: MalformedURLException) { - URL("http://$url") -} +fun sloppyLinkToStrictURL(url: String): URL = + try { + // If no exception, it's valid + URL(url) + } catch (_: MalformedURLException) { + URL("http://$url") + } /** * Returns a URL but does not guarantee that it accurately represents the input string if the input string is an invalid URL. * This is used to ensure that migrations to versions where Feeds have URL and not strings don't crash. */ -fun sloppyLinkToStrictURLNoThrows(url: String): URL = try { - sloppyLinkToStrictURL(url) -} catch (_: MalformedURLException) { - sloppyLinkToStrictURL("") -} +fun sloppyLinkToStrictURLNoThrows(url: String): URL = + try { + sloppyLinkToStrictURL(url) + } catch (_: MalformedURLException) { + sloppyLinkToStrictURL("") + } /** * On error, this method simply returns the original link. It does *not* throw exceptions. */ -fun relativeLinkIntoAbsoluteOrNull(base: URL, link: String?): String? = try { - // If no exception, it's valid - if (link != null) { - relativeLinkIntoAbsoluteOrThrow(base, link).toString() - } else { - null +fun relativeLinkIntoAbsoluteOrNull( + base: URL, + link: String?, +): String? = + try { + // If no exception, it's valid + if (link != null) { + relativeLinkIntoAbsoluteOrThrow(base, link).toString() + } else { + null + } + } catch (_: MalformedURLException) { + link } -} catch (_: MalformedURLException) { - link -} /** * On error, this method simply returns the original link. It does *not* throw exceptions. */ -fun relativeLinkIntoAbsolute(base: URL, link: String): String = try { - // If no exception, it's valid - relativeLinkIntoAbsoluteOrThrow(base, link).toString() -} catch (_: MalformedURLException) { - link -} +fun relativeLinkIntoAbsolute( + base: URL, + link: String, +): String = + try { + // If no exception, it's valid + relativeLinkIntoAbsoluteOrThrow(base, link).toString() + } catch (_: MalformedURLException) { + link + } /** * On error, throws MalformedURLException. */ @Throws(MalformedURLException::class) -fun relativeLinkIntoAbsoluteOrThrow(base: URL, link: String): URL = try { - // If no exception, it's valid - URL(link) -} catch (_: MalformedURLException) { - URL(base, link) -} +fun relativeLinkIntoAbsoluteOrThrow( + base: URL, + link: String, +): URL = + try { + // If no exception, it's valid + URL(link) + } catch (_: MalformedURLException) { + URL(base, link) + } private val regexImgSrc = """img.*?src=(["'])((?!data).*?)\1""".toRegex(RegexOption.DOT_MATCHES_ALL) @@ -171,25 +181,25 @@ fun naiveFindImageLink(text: String?): String? = null } -fun String.urlEncode(): String = - URLEncoder.encode(this, "UTF-8") +fun String.urlEncode(): String = URLEncoder.encode(this, "UTF-8") -fun String.urlDecode(): String = - URLDecoder.decode(this, "UTF-8") +fun String.urlDecode(): String = URLDecoder.decode(this, "UTF-8") -fun Context.unicodeWrap(text: String): String = - BidiFormatter.getInstance(getLocale()).unicodeWrap(text) +fun Context.unicodeWrap(text: String): String = BidiFormatter.getInstance(getLocale()).unicodeWrap(text) -fun Context.getLocale(): Locale = - resources.configuration.locales[0] +fun Context.getLocale(): Locale = resources.configuration.locales[0] -val FILE_DATETIME_FORMAT = LocalDateTime.Format { - date(LocalDate.Formats.ISO); - char('T'); hour(); char('_'); minute(); char('_'); second() -} +val FILE_DATETIME_FORMAT = + LocalDateTime.Format { + date(LocalDate.Formats.ISO) + char('T') + hour() + char('_') + minute() + char('_') + second() + } object Android { - fun sdk(sdk: Int): Boolean { - return Build.VERSION.SDK_INT >= sdk - } + fun sdk(sdk: Int): Boolean = Build.VERSION.SDK_INT >= sdk } diff --git a/app/src/main/java/com/saulhdev/feeder/viewmodels/ArticleListViewModel.kt b/app/src/main/java/com/saulhdev/feeder/viewmodels/ArticleListViewModel.kt index 89be9880..4017c233 100644 --- a/app/src/main/java/com/saulhdev/feeder/viewmodels/ArticleListViewModel.kt +++ b/app/src/main/java/com/saulhdev/feeder/viewmodels/ArticleListViewModel.kt @@ -21,9 +21,6 @@ package com.saulhdev.feeder.viewmodels import androidx.lifecycle.viewModelScope import com.saulhdev.feeder.data.content.FeedPreferences import com.saulhdev.feeder.data.db.models.FeedItem -import com.saulhdev.feeder.data.entity.SORT_CHRONOLOGICAL -import com.saulhdev.feeder.data.entity.SORT_SOURCE -import com.saulhdev.feeder.data.entity.SORT_TITLE import com.saulhdev.feeder.data.entity.SortFilterModel import com.saulhdev.feeder.data.repository.ArticleRepository import com.saulhdev.feeder.data.repository.SourcesRepository @@ -45,56 +42,62 @@ class ArticleListViewModel( ) : NeoViewModel() { private val ioScope = viewModelScope.plus(Dispatchers.IO) - private val sortFilterState = combine( - prefs.sortingFilter.get(), - prefs.sortingAsc.get(), - prefs.sourcesFilter.get(), - prefs.tagsFilter.get(), - ) { sort, sortAsc, sources, tags -> - SortFilterModel(sort, sortAsc, sources, tags) - } - .stateIn( + private val sortFilterState = + combine( + prefs.sortingFilter.get(), + prefs.sortingAsc.get(), + prefs.sourcesFilter.get(), + prefs.tagsFilter.get(), + ) { sort, sortAsc, sources, tags -> + SortFilterModel(sort, sortAsc, sources, tags) + }.stateIn( ioScope, SharingStarted.Eagerly, - SortFilterModel() + SortFilterModel(), ) @OptIn(ExperimentalCoroutinesApi::class) - val articleListState: StateFlow = combine( - prefs.tagsFilter.get().flatMapLatest { tags -> - if (tags.any()) articleRepo.getFeedItemsByTags(tags) - else articleRepo.getEnabledFeedItems() - }, - sortFilterState, - prefs.removeDuplicates.get(), - feedsRepo.isSyncing - ) { articles, sfm, removeDuplicate, isSyncing -> - ArticleListState( - articles = processArticles(articles, sfm, removeDuplicate), - isFilterModified = sfm != SortFilterModel(), - isSyncing = isSyncing + val articleListState: StateFlow = + combine( + sortFilterState.flatMapLatest { sfm -> + val tags = sfm.tagsFilter + if (tags.any()) { + articleRepo.getFeedItemsByTagsSorted(tags, sfm.sort, sfm.sortAsc) + } else { + articleRepo.getEnabledFeedItemsSorted(sfm.sort, sfm.sortAsc) + } + }, + sortFilterState, + prefs.removeDuplicates.get(), + feedsRepo.isSyncing, + ) { articles, sfm, removeDuplicate, isSyncing -> + ArticleListState( + articles = filterArticles(articles, sfm, removeDuplicate), + isFilterModified = sfm != SortFilterModel(), + isSyncing = isSyncing, + ) + }.stateIn( + ioScope, + SharingStarted.Eagerly, + ArticleListState(), ) - }.stateIn( - ioScope, - SharingStarted.Eagerly, - ArticleListState() - ) - val bookmarksState: StateFlow = combine( - articleRepo.getBookmarkedFeedItems(), - sortFilterState, - prefs.removeDuplicates.get(), - feedsRepo.isSyncing - ) { articles, sfm, removeDuplicate, isSyncing -> - BookmarksState( - bookmarkedArticles = processArticles(articles, sfm, removeDuplicate), - isSyncing = isSyncing + val bookmarksState: StateFlow = + combine( + articleRepo.getBookmarkedFeedItems(), + sortFilterState, + prefs.removeDuplicates.get(), + feedsRepo.isSyncing, + ) { articles, sfm, removeDuplicate, isSyncing -> + BookmarksState( + bookmarkedArticles = filterArticles(articles, sfm, removeDuplicate), + isSyncing = isSyncing, + ) + }.stateIn( + ioScope, + SharingStarted.Eagerly, + BookmarksState(), ) - }.stateIn( - ioScope, - SharingStarted.Eagerly, - BookmarksState() - ) fun unpinArticle(id: String) { viewModelScope.launch { @@ -102,54 +105,29 @@ class ArticleListViewModel( } } - fun bookmarkArticle(id: String, boolean: Boolean) { + fun bookmarkArticle( + id: String, + boolean: Boolean, + ) { viewModelScope.launch { articleRepo.bookmarkArticle(id, boolean) } } - // HELPERS - private fun processArticles( + private fun filterArticles( articles: List, sfm: SortFilterModel, - removeDuplicate: Boolean - ): List { - return articles + removeDuplicate: Boolean, + ): List = + articles .let { if (removeDuplicate) it.distinctBy { item -> item.link } else it } .let { list -> - if (sfm.sourcesFilter.isEmpty()) list - else list.filterNot { it.sourceId in sfm.sourcesFilter } - } - .let { list -> - if (sfm.tagsFilter.isEmpty()) list - else list.filterNot { it.feedTag in sfm.tagsFilter } - } - .let { list -> - when (sfm.sort) { - SORT_CHRONOLOGICAL if !sfm.sortAsc -> - list.sortedByDescending(FeedItem::timeMillis) - - SORT_CHRONOLOGICAL if sfm.sortAsc -> - list.sortedBy(FeedItem::timeMillis) - - SORT_TITLE if sfm.sortAsc -> - list.sortedBy(FeedItem::contentTitle) - - SORT_TITLE if !sfm.sortAsc -> - list.sortedByDescending(FeedItem::contentTitle) - - SORT_SOURCE if sfm.sortAsc -> - list.sortedBy(FeedItem::displayTitle) - - SORT_SOURCE if !sfm.sortAsc -> - list.sortedByDescending(FeedItem::displayTitle) - - else -> list.sortedByDescending( - FeedItem::timeMillis - ) + if (sfm.sourcesFilter.isEmpty()) { + list + } else { + list.filterNot { it.sourceId in sfm.sourcesFilter } } } - } } data class ArticleListState( diff --git a/app/src/main/java/com/saulhdev/feeder/viewmodels/SearchFeedViewModel.kt b/app/src/main/java/com/saulhdev/feeder/viewmodels/SearchFeedViewModel.kt index 90aa83d9..0b9953d3 100644 --- a/app/src/main/java/com/saulhdev/feeder/viewmodels/SearchFeedViewModel.kt +++ b/app/src/main/java/com/saulhdev/feeder/viewmodels/SearchFeedViewModel.kt @@ -29,15 +29,19 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapNotNull import kotlinx.parcelize.Parcelize +import okhttp3.OkHttpClient import java.net.URL -class SearchFeedViewModel : NeoViewModel() { - private val feedParser: FeedParser = FeedParser() +class SearchFeedViewModel( + okHttpClient: OkHttpClient, +) : NeoViewModel() { + private val feedParser: FeedParser = FeedParser(okHttpClient) fun searchForFeeds(url: URL) = flow { emit(url) - feedParser.getAlternateFeedLinksAtUrl(url) + feedParser + .getAlternateFeedLinksAtUrl(url) .forEach { emit(sloppyLinkToStrictURL(it.first)) } @@ -48,7 +52,7 @@ class SearchFeedViewModel : NeoViewModel() { title = feed.title ?: "", url = feed.feed_url ?: it.toString(), description = feed.description ?: "", - isError = false + isError = false, ) } } catch (t: Throwable) { @@ -57,11 +61,10 @@ class SearchFeedViewModel : NeoViewModel() { title = FAILED_TO_PARSE_PLACEHOLDER, url = it.toString(), description = t.message ?: "", - isError = true + isError = true, ) } - } - .flowOn(Dispatchers.Default) + }.flowOn(Dispatchers.Default) companion object { const val FAILED_TO_PARSE_PLACEHOLDER = "failed_to_parse" diff --git a/app/src/main/java/com/saulhdev/feeder/viewmodels/SourceListViewModel.kt b/app/src/main/java/com/saulhdev/feeder/viewmodels/SourceListViewModel.kt index feccbd6f..89db2325 100644 --- a/app/src/main/java/com/saulhdev/feeder/viewmodels/SourceListViewModel.kt +++ b/app/src/main/java/com/saulhdev/feeder/viewmodels/SourceListViewModel.kt @@ -20,27 +20,29 @@ class SourceListViewModel( ) : NeoViewModel() { private val ioScope = viewModelScope.plus(Dispatchers.IO) - val state = combine( - feedsRepo.getAllSourcesFlow(), - feedsRepo.getAllTagsFlow(), - // TODO move the getter eventually to SourcesRepository - articleRepo.getBookmarkedFeedItems() - ) { allSources, allTags, bookmarked -> - val (enabledSources, disabledSources) = allSources.partition { it.isEnabled } - SourceListState( - allSources = allSources, - enabledSources = enabledSources, - disabledSources = disabledSources, - tagsSourcesMap = allTags.plus("").associateWith { tag -> - allSources.filter { it.tag.contains(tag) } - }, - bookmarked = bookmarked, + val state = + combine( + feedsRepo.getAllSourcesFlow(), + feedsRepo.getAllTagsFlow(), + // TODO move the getter eventually to SourcesRepository + articleRepo.getBookmarkedFeedItems(), + ) { allSources, allTags, bookmarked -> + val (enabledSources, disabledSources) = allSources.partition { it.isEnabled } + SourceListState( + allSources = allSources, + enabledSources = enabledSources, + disabledSources = disabledSources, + tagsSourcesMap = + allTags.plus("").associateWith { tag -> + allSources.filter { it.tag.contains(tag) } + }, + bookmarked = bookmarked, + ) + }.stateIn( + ioScope, + SharingStarted.Eagerly, + SourceListState(), ) - }.stateIn( - ioScope, - SharingStarted.Eagerly, - SourceListState() - ) fun insertFeed(feed: Feed) { viewModelScope.launch { @@ -48,7 +50,16 @@ class SourceListViewModel( } } - fun updateFeed(source: Feed, enable: Boolean) { + fun deleteFeed(feedId: Long) { + viewModelScope.launch { + feedsRepo.deleteFeed(feedId) + } + } + + fun updateFeed( + source: Feed, + enable: Boolean, + ) { viewModelScope.launch { feedsRepo.updateSource(source, enable) } @@ -59,12 +70,13 @@ class SourceListViewModel( if (result.isError) { return@forEach } else { - val feed = Feed( - title = result.title, - description = result.description, - url = sloppyLinkToStrictURL(result.url), - feedImage = sloppyLinkToStrictURL(result.url) - ) + val feed = + Feed( + title = result.title, + description = result.description, + url = sloppyLinkToStrictURL(result.url), + feedImage = sloppyLinkToStrictURL(result.url), + ) insertFeed(feed) } } diff --git a/app/src/main/res/color/botttom_sheet_scrim.xml b/app/src/main/res/color/bottom_sheet_scrim.xml similarity index 100% rename from app/src/main/res/color/botttom_sheet_scrim.xml rename to app/src/main/res/color/bottom_sheet_scrim.xml diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 07ca11fd..e0f7f61c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -106,4 +106,66 @@ Afficher les marque-pages Cacher les marque-pages Marque-pages + Voir vos articles en marque-pages + Service + Autres + Navigateur web introuvable + Superposition + Ajouter un flux + Supprimer les articles en double provenant de sources différentes + Automatiquement (à partir du système ; noir) + Noir + Trier et filtrer + Modifié + Appliquer + Réinitialiser + Ordre de tri + Chronologique + Titre + Source + Croissant + Décroissant + Tout sélectionner + Tout désélectionner + Élément sélectionné + Faire glisser pour réduire le volet + Icône de section + Développer la section + Réduire la section + %1$s à %2$s + Cette application nécessite l\'autorisation de superposition pour que les articles s\'ouvrent au clic + Accéder aux paramètres + Fichier depuis Neo Feed + Importer les marque-pages + Exporter les marque-pages + Activé + Désactivé + Supprimer + Enregistrer + Ouvrir dans SMRY + Partager le flux + Où envoyer ? + Flux suggérés + Déjà ajouté + Rechercher des flux + Tout ajouter + Tout retirer + Français + English + Espagnol + YouTube + Actualités + Tech & Numérique + Science & Espace + Lifestyle & Féminin + Cuisine + Sport + Jeux vidéo + Crypto + Auto / Moto + Android + Open Source + Design + Finance + YouTube diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b86dc0d9..aa8ce869 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -144,4 +144,10 @@ Salva Apri in SMRY %1$s alle %2$s + Intervallo di sincronizzazione + 1 giorno + 2 giorni + 3 giorni + 1 settimana + 1 mese diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml new file mode 100644 index 00000000..2a9873d2 --- /dev/null +++ b/app/src/main/res/values-pt/strings.xml @@ -0,0 +1,28 @@ + + + Configurações + Sobre + Fontes dos dados + Adicionar ou remover fontes + agora + em + hoje + amanhã + ontem + Contactos + Membros da equipa + Canal + Comunidade + Comunidade do Telegram + Comunidade no Matriz + Informação da build + Programadora + Licença + Voltar ao topo + Relatório de Mudanças + Bibliotecas de código aberto + Canal do Telegram + Código-fonte + Aparência + Depurar + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 26796c32..2a113943 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -82,4 +82,71 @@ Makalenin tamamı varsayılan olarak getir Çevrimdışı makale bulunamadı %1$s%2$s - \ No newline at end of file + Ana Sayfa + Takım Üyeleri + Kanal + Topluluk + Telegram Topluluğu + Matrix Topluluğu + Derleme bilgisi + Geliştirici + Başa Dön + Dinamik Renk + Senkronizasyon Aralığı + Etiketler + 1 gün + 2 gün + 3 gün + 1 hafta + 1 ay + Hiçbir makale bulunamadı + Etkin + OPML dosyasından akışları aktar + OPML dosyasına akışları çıkart + OPML dosyası içe aktarılamadı + OPML dosyası dışa aktarılamadı + Paylaş + Akışı sil + Yer İşareti + Yer işaretini kaldır + Yer işaretlerini göster + Yer işaretlerini gizle + Yer İşaretleri + Yer işaretli makalelerini gör + Servis + Diğer + Web tarayıcısı bulunamadı + Katman + Akış ekle + Farklı kaynaklardaki yinelenen makaleleri kaldır + Otomatik (Sistemden; siyah) + Siyah + Sırala ve filtrele + Düzenlenmiş + Uygula + Sıfırla + Sıralama düzeni + Kronolojik + Başlık + Kaynak + Artan + Azalan + Tümünü seç + Tümünün seçimini kaldır + Öğe seçildi + Kapatmak için aşağı kaydır + Bölüm ikonu + Bölümü genişler + Bölümü daralt + %1$s, saat %2$s + Bu uygulama diğer uygulamaların üzerinde görüntülenme izni gerektirir; bu izin verilmezse akış ögeleri tıklandığında açılmayacaktır + Ayarlara git + Neo Feed\'den Dosya + Yer işaretlerini içe aktar + Yer işaretlerini dışa aktar + Etkin + Devre Dışı + Sil + Kaydet + SMRY\'de Aç + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b8496ab6..e36fdd96 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -143,4 +143,10 @@ 保存 在 SMRY 中打开 文件来自 Neo Feed + 1 周 + 1 天 + 3 天 + 同步范围 + 2 天 + 1 个月 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 94bf758d..d6df68c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -163,4 +163,29 @@ Delete Save Open in SMRY - \ No newline at end of file + Share feed + Where to send? + Suggested feeds + Already added + Search feeds + Add all + Remove all + Français + English + Español + YouTube + News + Tech + Science + Lifestyle + Cooking + Sport + Gaming + Crypto + Auto / Moto + Android + Open Source + Design + Finance + YouTube + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 348511e9..683208ff 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,11 +1,8 @@ - - file:///android_asset/ - - + - \ No newline at end of file + diff --git a/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayController.java b/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayController.java index f5c25ffa..aa4d0065 100644 --- a/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayController.java +++ b/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayController.java @@ -147,9 +147,12 @@ public boolean isOpen() { public void setVisible(boolean visible) { if (visible) { - window.clearFlags(24); // FLAG_NOT_TOUCHABLE | FLAG_NOT_FOCUSABLE + // Make the overlay touchable so the feed can be interacted with, + // but keep it non-focusable so the launcher/IME target never moves + // away from the launcher window. + window.clearFlags(16); // FLAG_NOT_TOUCHABLE } else { - window.addFlags(24); + window.addFlags(16); // FLAG_NOT_TOUCHABLE } } diff --git a/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayControllerCallback.java b/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayControllerCallback.java index 741203dd..c0158951 100644 --- a/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayControllerCallback.java +++ b/google-gsa/src/main/java/com/google/android/libraries/gsa/d/a/OverlayControllerCallback.java @@ -163,11 +163,18 @@ private void setupOverlayController(OverlayController controller, Bundle args, L layoutParams.width = LayoutParams.MATCH_PARENT; layoutParams.height = LayoutParams.MATCH_PARENT; - layoutParams.flags |= 8650752; + // Start fully transparent, non-focusable and non-touchable so the overlay + // never briefly covers the launcher while it is being attached. + layoutParams.flags |= 8650752 + | LayoutParams.FLAG_NOT_FOCUSABLE + | LayoutParams.FLAG_NOT_TOUCHABLE; + layoutParams.alpha = 0f; layoutParams.dimAmount = 0f; layoutParams.gravity = 3; layoutParams.type = 4; - layoutParams.softInputMode = LayoutParams.SOFT_INPUT_STATE_VISIBLE; + // Don't override the launcher window's soft-input behaviour; otherwise + // the app-drawer search box can't show its own keyboard. + layoutParams.softInputMode = LayoutParams.SOFT_INPUT_STATE_UNSPECIFIED; controller.window.setAttributes(layoutParams); controller.window.clearFlags(1048576);