From c6e46dab511376c2e8a45223697c9e178492cc03 Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Fri, 10 Jul 2026 23:01:11 +0200 Subject: [PATCH 01/15] fix(overlay): keep feed overlay non-focusable to allow launcher keyboard input When Neo-Feed is enabled as a launcher feed provider, the overlay window was temporarily made focusable while visible. This stole focus from the launcher, preventing the soft keyboard from appearing in the app drawer search field. Keep FLAG_NOT_FOCUSABLE set at all times and only toggle FLAG_NOT_TOUCHABLE when showing/hiding the overlay. Also start the overlay with alpha 0 so it does not cover content before the first open gesture. Fixes keyboard not showing in Neo-Launcher app drawer when Neo-Feed is enabled. --- .../saulhdev/feeder/manager/service/OverlayView.kt | 11 +++++++++-- .../android/libraries/gsa/d/a/OverlayController.java | 7 +++++-- .../libraries/gsa/d/a/OverlayControllerCallback.java | 11 +++++++++-- 3 files changed, 23 insertions(+), 6 deletions(-) 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..aa396db5 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 @@ -76,6 +76,11 @@ class OverlayView(val context: Context) : themeHolder = OverlayThemeHolder(this) + // 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() refreshNotifications() @@ -329,8 +334,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)) } 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); From 7fab8610a9777142ee28a43d9c4353bd15b45452 Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Fri, 10 Jul 2026 23:18:08 +0200 Subject: [PATCH 02/15] Translations update from PR #58 --- app/src/main/res/values-it/strings.xml | 6 ++ app/src/main/res/values-pt/strings.xml | 28 +++++++++ app/src/main/res/values-tr/strings.xml | 69 +++++++++++++++++++++- app/src/main/res/values-zh-rCN/strings.xml | 6 ++ 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/values-pt/strings.xml 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 个月 From 072e375f88898099f56ff7bff9144a1c0a12cbb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane?= Date: Sun, 15 Feb 2026 12:58:15 +0100 Subject: [PATCH 03/15] fix: resolve multiple upstream bugs (#27, #30, #42, #57) - #30: sync settings now reconfigure WorkManager when changed - #42: set overlay background immediately in onCreate to prevent flash - #57: add lower items-per-feed options (1, 3, 5, 10) - #27: use pure black card background in AMOLED theme --- .../java/com/saulhdev/feeder/MainActivity.kt | 98 ++++++----- .../feeder/manager/service/OverlayView.kt | 161 +++++++++++------- .../com/saulhdev/feeder/ui/theme/CardTheme.kt | 92 +++++----- .../com/saulhdev/feeder/utils/FeederUtils.kt | 132 +++++++------- 4 files changed, 271 insertions(+), 212 deletions(-) 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/manager/service/OverlayView.kt b/app/src/main/java/com/saulhdev/feeder/manager/service/OverlayView.kt index 6e0fed51..162705c2 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,21 @@ 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) + val bgColor = themeHolder.currentTheme.get(CardTheme.Colors.OVERLAY_BG.ordinal) + val color = (prefs.overlayTransparency.getValue() * 255.0f).toInt() shl 24 or (bgColor and 0x00ffffff) + getWindow().setBackgroundDrawable(ColorDrawable(color)) initRecyclerView() initHeader() @@ -136,45 +143,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 +196,6 @@ class OverlayView(val context: Context) : setOnClickListener { visibility = View.GONE recyclerView.smoothScrollToPosition(0) - } } @@ -198,52 +210,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 +314,6 @@ class OverlayView(val context: Context) : } } - rootView.findViewById(R.id.header_settings).apply { setOnClickListener { openMenu(it) @@ -296,18 +326,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() } @@ -364,11 +394,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/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/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 } From a071b465154837c37a21d3b9df086dfc42731341 Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Fri, 10 Jul 2026 23:29:51 +0200 Subject: [PATCH 04/15] Apply DB migration v8 and safe cleanups from foXaCe refactor - Add Room auto-migration from v7 to v8 (removes Feeds index) - Remove redundant unique index from Feed entity - Fix typo in bottom_sheet_scrim resource filename - Tighten network_security_config (disable cleartext by default) Build-config and code-style portions of the original refactor are omitted because they conflict with upstream's current setup. --- .../8.json | 273 ++++++++++++++++++ .../com/saulhdev/feeder/data/db/NeoFeedDb.kt | 6 +- .../saulhdev/feeder/data/db/models/Feed.kt | 3 +- .../feeder/ui/views/BaseBottomSheet.kt | 2 +- ...sheet_scrim.xml => bottom_sheet_scrim.xml} | 0 .../main/res/xml/network_security_config.xml | 7 +- 6 files changed, 282 insertions(+), 9 deletions(-) create mode 100644 app/schemas/com.saulhdev.feeder.data.db.NeoFeedDb/8.json rename app/src/main/res/color/{botttom_sheet_scrim.xml => bottom_sheet_scrim.xml} (100%) 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/data/db/NeoFeedDb.kt b/app/src/main/java/com/saulhdev/feeder/data/db/NeoFeedDb.kt index 3cb33076..7e9d9a38 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 = 8, exportSchema = true, autoMigrations = [ AutoMigration( @@ -69,6 +69,10 @@ const val ID_ALL: Long = -1L to = 7, spec = NeoFeedDb.RemoveLegacyPubDate::class ), + AutoMigration( + from = 7, + to = 8, + ), ], views = [ ArticleIdWithLink::class, 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/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/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/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 + From 7260706f5c331404c5335c1d5cdeb3616a868afc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane?= Date: Sun, 15 Feb 2026 18:14:05 +0100 Subject: [PATCH 05/15] feat: add suggested feeds page with predefined RSS sources by category Adds a discoverable page with curated RSS feeds organized by theme (Tech, Android, News, Science, Open Source, YouTube) accessible from the Sources overflow menu. Feeds can be added with one tap and show a checkmark when already present. --- .../feeder/data/entity/SuggestedFeeds.kt | 144 ++++++++++++++++ .../feeder/ui/navigation/NavigationManager.kt | 7 +- .../feeder/ui/pages/SourceListPage.kt | 162 ++++++++++-------- .../feeder/ui/pages/SuggestedFeedsPage.kt | 129 ++++++++++++++ app/src/main/res/values-fr/strings.xml | 41 +++++ app/src/main/res/values/strings.xml | 6 +- 6 files changed, 419 insertions(+), 70 deletions(-) create mode 100644 app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt create mode 100644 app/src/main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt 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..ecbca71d --- /dev/null +++ b/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt @@ -0,0 +1,144 @@ +/* + * 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 + +data class SuggestedFeed( + val title: String, + val url: String, + val description: String, +) + +object SuggestedFeedsData { + val categories: Map> = + mapOf( + "Tech" to + 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", + ), + ), + "Android" to + 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", + ), + ), + "News" to + 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 = "Le Monde", + url = "https://www.lemonde.fr/rss/une.xml", + description = "Actualités internationales et françaises", + ), + ), + "Science" to + 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", + ), + ), + "Open Source" to + 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", + ), + ), + "YouTube" to + listOf( + 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/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..4f6d9035 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 @@ -62,6 +62,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 +86,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 +164,7 @@ fun SourceListPage( navController.navigate(NavRoute.SourceAdd) }, modifier = Modifier.padding(16.dp), - shape = MaterialTheme.shapes.extraLarge + shape = MaterialTheme.shapes.extraLarge, ) { Icon( imageVector = Phosphor.Plus, @@ -180,11 +188,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 +203,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 +221,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 +236,36 @@ 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)) }, + ) + DropdownMenuItem( + leadingIcon = { + Icon( + Phosphor.Megaphone, + contentDescription = stringResource(id = R.string.suggested_feeds), + ) + }, + onClick = { + hideMenu() + navController.navigate(NavRoute.SuggestedFeeds) + }, + text = { Text(text = stringResource(id = R.string.suggested_feeds)) }, ) } - } + }, ) { 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 +279,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 +302,7 @@ fun SourceListPage( scope.launch { paneNavigator.navigateTo( ListDetailPaneScaffoldRole.Detail, - item.id + item.id, ) } }, @@ -288,7 +311,7 @@ fun SourceListPage( it.copy(isEnabled = true), true, ) - } + }, ) } item { @@ -299,9 +322,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 +338,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..694d35da --- /dev/null +++ b/app/src/main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt @@ -0,0 +1,129 @@ +/* + * 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.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.saulhdev.feeder.R +import com.saulhdev.feeder.data.db.models.Feed +import com.saulhdev.feeder.data.entity.SuggestedFeed +import com.saulhdev.feeder.data.entity.SuggestedFeedsData +import com.saulhdev.feeder.ui.components.PreferenceGroupHeading +import com.saulhdev.feeder.ui.components.ViewWithActionBar +import com.saulhdev.feeder.ui.icons.Phosphor +import com.saulhdev.feeder.ui.icons.phosphor.CheckCircle +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 + +@Composable +fun SuggestedFeedsPage( + viewModel: SourceListViewModel = koinNeoViewModel(), +) { + val state by viewModel.state.collectAsState() + val existingUrls = state.allSources.map { it.url.toString() }.toSet() + + 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(4.dp), + ) { + SuggestedFeedsData.categories.forEach { (category, feeds) -> + item(key = "header_$category") { + PreferenceGroupHeading(heading = category) + } + items(feeds, key = { it.url }) { suggestedFeed -> + SuggestedFeedItem( + feed = suggestedFeed, + isAdded = existingUrls.contains(suggestedFeed.url), + onAdd = { + viewModel.insertFeed( + Feed( + title = suggestedFeed.title, + url = sloppyLinkToStrictURL(suggestedFeed.url), + description = suggestedFeed.description, + tag = category, + ), + ) + }, + ) + } + } + } + } +} + +@Composable +private fun SuggestedFeedItem( + feed: SuggestedFeed, + isAdded: Boolean, + onAdd: () -> Unit, +) { + ListItem( + modifier = if (!isAdded) Modifier.clickable(onClick = onAdd) else Modifier, + headlineContent = { + Text(text = feed.title) + }, + supportingContent = { + Text(text = feed.description) + }, + trailingContent = { + if (isAdded) { + 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/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 07ca11fd..2dbb979a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -106,4 +106,45 @@ 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é diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 94bf758d..cf9c7dd0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -163,4 +163,8 @@ Delete Save Open in SMRY - \ No newline at end of file + Share feed + Where to send? + Suggested feeds + Already added + From 0b22bda8f5da6f6c4a8ac46ceaa0a52a6610aa8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane?= Date: Sun, 15 Feb 2026 18:20:12 +0100 Subject: [PATCH 06/15] feat: add French sources and move suggested feeds button to action bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add France category with Numerama, Frandroid, Next, Korben, Le Monde, Libération and France 24. Move suggested feeds button from overflow menu to a visible icon in the action bar. --- .../feeder/data/entity/SuggestedFeeds.kt | 43 ++++++++++++++++--- .../feeder/ui/pages/SourceListPage.kt | 24 +++++------ 2 files changed, 49 insertions(+), 18 deletions(-) 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 index ecbca71d..9e71a896 100644 --- a/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt +++ b/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt @@ -68,6 +68,44 @@ object SuggestedFeedsData { description = "Android, computing, and tech enthusiast community", ), ), + "France" to + 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 = "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", + ), + ), "News" to listOf( SuggestedFeed( @@ -80,11 +118,6 @@ object SuggestedFeedsData { url = "https://feeds.bbci.co.uk/news/rss.xml", description = "World news from the BBC", ), - SuggestedFeed( - title = "Le Monde", - url = "https://www.lemonde.fr/rss/une.xml", - description = "Actualités internationales et françaises", - ), ), "Science" to listOf( 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 4f6d9035..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 @@ -173,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 = { @@ -240,19 +251,6 @@ fun SourceListPage( }, text = { Text(text = stringResource(id = R.string.sources_export_bookmarks)) }, ) - DropdownMenuItem( - leadingIcon = { - Icon( - Phosphor.Megaphone, - contentDescription = stringResource(id = R.string.suggested_feeds), - ) - }, - onClick = { - hideMenu() - navController.navigate(NavRoute.SuggestedFeeds) - }, - text = { Text(text = stringResource(id = R.string.suggested_feeds)) }, - ) } }, ) { paddingValues -> From f58f5705e49655d28aa3a1e3dd30e561d88cef4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane?= Date: Sun, 15 Feb 2026 18:26:48 +0100 Subject: [PATCH 07/15] feat: organize suggested feeds by language, add more French sources, allow removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure suggested feeds with language groups (Français, English, YouTube). Add French sources: France Info, 20 Minutes, Ouest-France, Clubic, 01net, Journal du Geek, PhonAndroid, Futura Sciences, Cité des Sciences. Allow removing feeds by tapping the checkmark icon. --- .../feeder/data/entity/SuggestedFeeds.kt | 370 +++++++++++------- .../feeder/ui/pages/SuggestedFeedsPage.kt | 72 ++-- .../feeder/viewmodels/SourceListViewModel.kt | 66 ++-- 3 files changed, 308 insertions(+), 200 deletions(-) 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 index 9e71a896..3c45a0ec 100644 --- a/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt +++ b/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt @@ -24,154 +24,228 @@ data class SuggestedFeed( val description: String, ) +data class FeedLanguageGroup( + val language: String, + val categories: Map>, +) + object SuggestedFeedsData { - val categories: Map> = - mapOf( - "Tech" to - 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", - ), - ), - "Android" to - 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", - ), - ), - "France" to - 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 = "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", - ), - ), - "News" to - 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", - ), - ), - "Science" to - 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", - ), - ), - "Open Source" to - 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", - ), - ), - "YouTube" to - listOf( - 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", - ), - ), + val groups: List = + listOf( + FeedLanguageGroup( + language = "Français", + categories = + mapOf( + "Actualités" to + 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", + ), + ), + "Tech & Numérique" to + 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", + ), + ), + "Science & Espace" to + 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", + ), + ), + ), + ), + FeedLanguageGroup( + language = "English", + categories = + mapOf( + "Tech" to + 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", + ), + ), + "Android" to + 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", + ), + ), + "News" to + 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", + ), + ), + "Science" to + 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", + ), + ), + "Open Source" to + 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", + ), + ), + ), + ), + FeedLanguageGroup( + language = "YouTube", + categories = + mapOf( + "YouTube" to + listOf( + 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/ui/pages/SuggestedFeedsPage.kt b/app/src/main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt index 694d35da..8c53850d 100644 --- a/app/src/main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt +++ b/app/src/main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Icon @@ -34,7 +35,9 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier 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.SuggestedFeed @@ -53,7 +56,7 @@ fun SuggestedFeedsPage( viewModel: SourceListViewModel = koinNeoViewModel(), ) { val state by viewModel.state.collectAsState() - val existingUrls = state.allSources.map { it.url.toString() }.toSet() + val existingUrlMap = state.allSources.associate { it.url.toString() to it.id } ViewWithActionBar( title = stringResource(R.string.suggested_feeds), @@ -70,26 +73,42 @@ fun SuggestedFeedsPage( ), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - SuggestedFeedsData.categories.forEach { (category, feeds) -> - item(key = "header_$category") { - PreferenceGroupHeading(heading = category) - } - items(feeds, key = { it.url }) { suggestedFeed -> - SuggestedFeedItem( - feed = suggestedFeed, - isAdded = existingUrls.contains(suggestedFeed.url), - onAdd = { - viewModel.insertFeed( - Feed( - title = suggestedFeed.title, - url = sloppyLinkToStrictURL(suggestedFeed.url), - description = suggestedFeed.description, - tag = category, - ), - ) - }, + SuggestedFeedsData.groups.forEach { group -> + item(key = "lang_${group.language}") { + Text( + text = group.language, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp, start = 8.dp), ) } + group.categories.forEach { (category, feeds) -> + item(key = "header_${group.language}_$category") { + PreferenceGroupHeading(heading = category) + } + items(feeds, key = { "${group.language}_${it.url}" }) { suggestedFeed -> + val feedId = existingUrlMap[suggestedFeed.url] + SuggestedFeedItem( + feed = suggestedFeed, + isAdded = feedId != null, + onAdd = { + viewModel.insertFeed( + Feed( + title = suggestedFeed.title, + url = sloppyLinkToStrictURL(suggestedFeed.url), + description = suggestedFeed.description, + tag = category, + ), + ) + }, + onRemove = { + feedId?.let { viewModel.deleteFeed(it) } + }, + ) + } + } } } } @@ -100,9 +119,10 @@ private fun SuggestedFeedItem( feed: SuggestedFeed, isAdded: Boolean, onAdd: () -> Unit, + onRemove: () -> Unit, ) { ListItem( - modifier = if (!isAdded) Modifier.clickable(onClick = onAdd) else Modifier, + modifier = Modifier.clickable(onClick = if (isAdded) onRemove else onAdd), headlineContent = { Text(text = feed.title) }, @@ -111,11 +131,13 @@ private fun SuggestedFeedItem( }, trailingContent = { if (isAdded) { - Icon( - imageVector = Phosphor.CheckCircle, - contentDescription = stringResource(R.string.feed_already_added), - tint = MaterialTheme.colorScheme.primary, - ) + IconButton(onClick = onRemove) { + Icon( + imageVector = Phosphor.CheckCircle, + contentDescription = stringResource(R.string.feed_already_added), + tint = MaterialTheme.colorScheme.primary, + ) + } } else { IconButton(onClick = onAdd) { Icon( 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) } } From f639bb95b3f72e4b1ff78a1bd31d39d94b97dcf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane?= Date: Sun, 15 Feb 2026 18:33:50 +0100 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20add=20AlloCin=C3=A9=20trailers=20?= =?UTF-8?q?and=20French=20lifestyle/women=20feeds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AlloCiné Bandes Annonces YouTube channel. Add Lifestyle & Féminin category with Madmoizelle, Cosmopolitan, Marie Claire, Elle, Grazia and Aufeminin. --- .../feeder/data/entity/SuggestedFeeds.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) 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 index 3c45a0ec..184869fa 100644 --- a/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt +++ b/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt @@ -125,6 +125,39 @@ object SuggestedFeedsData { description = "Actualités scientifiques", ), ), + "Lifestyle & Féminin" to + 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é", + ), + ), ), ), FeedLanguageGroup( @@ -229,6 +262,11 @@ object SuggestedFeedsData { mapOf( "YouTube" to 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", From 7a063e5bcbe8293590db601453f87cccc18ae05f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane?= Date: Sun, 15 Feb 2026 18:49:16 +0100 Subject: [PATCH 09/15] feat: enhance suggested feeds with search, categories, icons and bulk actions - Add search/filter bar for quick feed discovery - Add collapsible language groups and category sections - Add category icons (Globe, Newspaper, GameController, etc.) - Add counter showing added/total feeds per category - Add "Add all" / "Remove all" bulk actions per category - Add new categories: Cuisine, Sport, Gaming, Auto/Moto, Design, Finance - Add i18n for all category and language names (EN + FR) - Disable ktlint backing-property-naming rule for icon files --- .../feeder/data/entity/SuggestedFeeds.kt | 702 ++++++++++++------ .../saulhdev/feeder/ui/icons/phosphor/Car.kt | 99 +++ .../ui/icons/phosphor/CurrencyDollar.kt | 78 ++ .../feeder/ui/icons/phosphor/ForkKnife.kt | 76 ++ .../ui/icons/phosphor/GameController.kt | 98 +++ .../feeder/ui/icons/phosphor/Globe.kt | 95 +++ .../ui/icons/phosphor/MagnifyingGlass.kt | 51 ++ .../feeder/ui/icons/phosphor/Newspaper.kt | 75 ++ .../feeder/ui/icons/phosphor/Trophy.kt | 87 +++ .../feeder/ui/pages/SuggestedFeedsPage.kt | 293 +++++++- app/src/main/res/values-fr/strings.xml | 19 + app/src/main/res/values/strings.xml | 19 + 12 files changed, 1416 insertions(+), 276 deletions(-) create mode 100644 app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Car.kt create mode 100644 app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/CurrencyDollar.kt create mode 100644 app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/ForkKnife.kt create mode 100644 app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/GameController.kt create mode 100644 app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Globe.kt create mode 100644 app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/MagnifyingGlass.kt create mode 100644 app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Newspaper.kt create mode 100644 app/src/main/java/com/saulhdev/feeder/ui/icons/phosphor/Trophy.kt 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 index 184869fa..4133e02d 100644 --- a/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt +++ b/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt @@ -18,271 +18,487 @@ 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 language: String, - val categories: Map>, + val key: String, + val icon: ImageVector, + val categories: List, ) object SuggestedFeedsData { val groups: List = listOf( FeedLanguageGroup( - language = "Français", + key = "fr", + icon = Phosphor.Globe, categories = - mapOf( - "Actualités" to - 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", - ), - ), - "Tech & Numérique" to - 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", - ), - ), - "Science & Espace" to - 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", - ), - ), - "Lifestyle & Féminin" to - 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é", - ), - ), + 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 = "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( - language = "English", + key = "en", + icon = Phosphor.Globe, categories = - mapOf( - "Tech" to - 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", - ), - ), - "Android" to - 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", - ), - ), - "News" to - 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", - ), - ), - "Science" to - 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", - ), - ), - "Open Source" to - 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", - ), - ), + 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", + ), + ), + ), + 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( - language = "YouTube", + key = "youtube", + icon = Phosphor.Play, categories = - mapOf( - "YouTube" to - 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", - ), - ), + 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/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/pages/SuggestedFeedsPage.kt b/app/src/main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt index 8c53850d..aefef151 100644 --- a/app/src/main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt +++ b/app/src/main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt @@ -18,45 +18,89 @@ 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.PreferenceGroupHeading 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, + "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, + "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), @@ -71,42 +115,155 @@ fun SuggestedFeedsPage( top = paddingValues.calculateTopPadding(), bottom = 8.dp, ), - verticalArrangement = Arrangement.spacedBy(4.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 -> - item(key = "lang_${group.language}") { - Text( - text = group.language, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 16.dp, bottom = 4.dp, start = 8.dp), - ) - } - group.categories.forEach { (category, feeds) -> - item(key = "header_${group.language}_$category") { - PreferenceGroupHeading(heading = category) + 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, + ) } - items(feeds, key = { "${group.language}_${it.url}" }) { suggestedFeed -> - val feedId = existingUrlMap[suggestedFeed.url] - SuggestedFeedItem( - feed = suggestedFeed, - isAdded = feedId != null, - onAdd = { - viewModel.insertFeed( - Feed( - title = suggestedFeed.title, - url = sloppyLinkToStrictURL(suggestedFeed.url), - description = suggestedFeed.description, - tag = category, - ), + } + + 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) }, ) - }, - onRemove = { - feedId?.let { viewModel.deleteFeed(it) } - }, - ) + } + + 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) } + }, + ) + } + } + } + } } } } @@ -114,6 +271,76 @@ fun SuggestedFeedsPage( } } +@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, diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 2dbb979a..b3c712de 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -147,4 +147,23 @@ Où envoyer ? Flux suggérés Déjà ajouté + Rechercher des flux + Tout ajouter + Tout retirer + Français + English + YouTube + Actualités + Tech & Numérique + Science & Espace + Lifestyle & Féminin + Cuisine + Sport + Jeux vidéo + Auto / Moto + Android + Open Source + Design + Finance + YouTube diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cf9c7dd0..5c4a14e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -167,4 +167,23 @@ Where to send? Suggested feeds Already added + Search feeds + Add all + Remove all + Français + English + YouTube + News + Tech + Science + Lifestyle + Cooking + Sport + Gaming + Auto / Moto + Android + Open Source + Design + Finance + YouTube From 164751b8a1de47613ed07e89de24f6fb1ed24122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane?= Date: Sun, 15 Feb 2026 19:54:33 +0100 Subject: [PATCH 10/15] feat: add French crypto feeds category Add 5 French crypto news sources: Journal du Coin, Cryptoast, CoinTribune, CoinAcademy, Cointelegraph FR --- .../feeder/data/entity/SuggestedFeeds.kt | 32 +++++++++++++++++++ .../feeder/ui/pages/SuggestedFeedsPage.kt | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 35 insertions(+) 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 index 4133e02d..bc7d82ac 100644 --- a/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt +++ b/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt @@ -265,6 +265,38 @@ object SuggestedFeedsData { ), ), ), + 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, 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 index aefef151..3fdbe988 100644 --- a/app/src/main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt +++ b/app/src/main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt @@ -84,6 +84,7 @@ private val categoryStringRes = "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, diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b3c712de..a1fbd373 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -160,6 +160,7 @@ Cuisine Sport Jeux vidéo + Crypto Auto / Moto Android Open Source diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c4a14e5..fae66046 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -180,6 +180,7 @@ Cooking Sport Gaming + Crypto Auto / Moto Android Open Source From ee85a223a036abe73874d77ab909a327eebfd150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane?= Date: Sun, 15 Feb 2026 20:14:35 +0100 Subject: [PATCH 11/15] perf: add HTTP cache, image cache, DB indices and SQL sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add centralized OkHttpClient singleton with 50MB disk cache via Koin DI - Configure Coil ImageLoader with 25% memory cache and 100MB disk cache - Add database indices on primarySortTime, bookmarked, pinned (migration v8→v9) - Move article sorting from in-memory ViewModel to SQL ORDER BY queries - Replace local OkHttpClient instances in FeedParser, RssLocalSync, FullTextWorker --- .../main/java/com/saulhdev/feeder/NeoApp.kt | 56 ++- .../com/saulhdev/feeder/data/db/NeoFeedDb.kt | 6 +- .../feeder/data/db/dao/FeedArticleDao.kt | 171 ++++++++-- .../saulhdev/feeder/data/db/models/Article.kt | 3 + .../data/repository/ArticleRepository.kt | 127 +++++-- .../feeder/manager/models/FeedParser.kt | 320 ++++++++++-------- .../feeder/manager/models/FullTextParser.kt | 103 +++--- .../feeder/manager/sync/RssLocalSync.kt | 5 +- .../feeder/viewmodels/ArticleListViewModel.kt | 142 ++++---- .../feeder/viewmodels/SearchFeedViewModel.kt | 17 +- 10 files changed, 607 insertions(+), 343 deletions(-) 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 7e9d9a38..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 = 8, + version = 9, exportSchema = true, autoMigrations = [ AutoMigration( @@ -73,6 +73,10 @@ const val ID_ALL: Long = -1L 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/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/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/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" From ba48b4eb98cdadf23a93805a1a6eea23736b6170 Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Fri, 10 Jul 2026 23:51:53 +0200 Subject: [PATCH 12/15] feat(suggested-feeds): add The Guardian, Spanish-language Latin American news group Add The Guardian (UK), teleSUR English, and Granma English to the English/news category. Add a new Spanish language group with news sources from Latin America: - La Jornada (Mexico) - teleSUR (Latin America) - Granma (Cuba) --- .../feeder/data/entity/SuggestedFeeds.kt | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) 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 index bc7d82ac..ac5ed323 100644 --- a/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt +++ b/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt @@ -390,6 +390,21 @@ object SuggestedFeedsData { 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 = "Granma English", + url = "https://en.granma.cu/feed", + description = "Cuban official newspaper in English", + ), ), ), SuggestedCategory( @@ -499,6 +514,35 @@ object SuggestedFeedsData { ), ), ), + 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", + ), + ), + ), + ), + ), FeedLanguageGroup( key = "youtube", icon = Phosphor.Play, From 177cc06270335247c3913ae0609d6b8a255c4b82 Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Fri, 10 Jul 2026 23:54:35 +0200 Subject: [PATCH 13/15] feat(suggested-feeds): add Central and South American news sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add English-language Costa Rican sources to English/news: - The Tico Times - Q Costa Rica Add Spanish-language sources from Central and South America to the Spanish/news group: - La Nación (Costa Rica) - El Ciudadano (Chile) - Tiempo Argentino (Argentina) --- .../feeder/data/entity/SuggestedFeeds.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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 index ac5ed323..eee74743 100644 --- a/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt +++ b/app/src/main/java/com/saulhdev/feeder/data/entity/SuggestedFeeds.kt @@ -400,6 +400,16 @@ object SuggestedFeedsData { 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", @@ -539,6 +549,21 @@ object SuggestedFeedsData { 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", + ), ), ), ), From 0719d6dc5b86155896b38a258a8ccb8a97a0bec7 Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Sat, 11 Jul 2026 00:00:31 +0200 Subject: [PATCH 14/15] feat(suggested-feeds): add Spanish language label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map the new Spanish language group key to a proper "Español" label instead of falling back to "English". --- .../main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 3 files changed, 3 insertions(+) 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 index 3fdbe988..9ab7cb3d 100644 --- a/app/src/main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt +++ b/app/src/main/java/com/saulhdev/feeder/ui/pages/SuggestedFeedsPage.kt @@ -72,6 +72,7 @@ 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, ) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a1fbd373..e0f7f61c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -152,6 +152,7 @@ Tout retirer Français English + Espagnol YouTube Actualités Tech & Numérique diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fae66046..d6df68c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -172,6 +172,7 @@ Remove all Français English + Español YouTube News Tech From f49e4ab9199a403b44fbae7eeeed5b4c9094a7c7 Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Sat, 11 Jul 2026 00:05:51 +0200 Subject: [PATCH 15/15] fix(overlay): resolve duplicate bgColor after merging keyboard fix Keep only the transparent initial background so the overlay does not cover the launcher before the first open gesture, while remaining compatible with the rest of the integration branch. --- .../java/com/saulhdev/feeder/manager/service/OverlayView.kt | 3 --- 1 file changed, 3 deletions(-) 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 86bf22b6..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 @@ -79,9 +79,6 @@ class OverlayView( themeHolder = OverlayThemeHolder(this) setTheme(force = null) - val bgColor = themeHolder.currentTheme.get(CardTheme.Colors.OVERLAY_BG.ordinal) - val color = (prefs.overlayTransparency.getValue() * 255.0f).toInt() shl 24 or (bgColor and 0x00ffffff) - getWindow().setBackgroundDrawable(ColorDrawable(color)) // Start fully transparent so the overlay never covers the launcher/folders // while it is closed (for example, before the first onScroll callback arrives).