diff --git a/capy/src/main/java/com/jocmp/capy/Account.kt b/capy/src/main/java/com/jocmp/capy/Account.kt
index 287ed54b6..a9efc743b 100644
--- a/capy/src/main/java/com/jocmp/capy/Account.kt
+++ b/capy/src/main/java/com/jocmp/capy/Account.kt
@@ -472,6 +472,10 @@ data class Account(
feedRecords.updateShowUnreadBadge(feedID, enabled)
}
+ suspend fun updateFeedImportance(feedID: String, importance: FeedImportance) {
+ feedRecords.updateImportance(feedID, importance)
+ }
+
suspend fun toggleAllFeedUnreadBadges(enabled: Boolean) {
feedRecords.toggleAllShowUnreadBadge(enabled)
}
diff --git a/capy/src/main/java/com/jocmp/capy/Feed.kt b/capy/src/main/java/com/jocmp/capy/Feed.kt
index 4fecaabde..e9901f975 100644
--- a/capy/src/main/java/com/jocmp/capy/Feed.kt
+++ b/capy/src/main/java/com/jocmp/capy/Feed.kt
@@ -21,4 +21,5 @@ data class Feed(
val priority: FeedPriority? = null,
val showUnreadBadge: Boolean = true,
val isReadLater: Boolean = false,
+ val importance: FeedImportance = FeedImportance.NORMAL,
): Countable
diff --git a/capy/src/main/java/com/jocmp/capy/FeedImportance.kt b/capy/src/main/java/com/jocmp/capy/FeedImportance.kt
new file mode 100644
index 000000000..338a5c6ce
--- /dev/null
+++ b/capy/src/main/java/com/jocmp/capy/FeedImportance.kt
@@ -0,0 +1,25 @@
+package com.jocmp.capy
+
+/**
+ * Feed importance bucket, modeled after Fraidycat.
+ *
+ * Higher importance = check more often, place higher in the surfaced feed list.
+ */
+enum class FeedImportance {
+ REAL_TIME,
+ DAILY,
+ NORMAL,
+ WEEKLY,
+ MONTHLY,
+ YEARLY;
+
+ val storageValue: String
+ get() = name
+
+ companion object {
+ fun parse(value: String?): FeedImportance {
+ if (value.isNullOrBlank()) return NORMAL
+ return entries.firstOrNull { it.name == value } ?: NORMAL
+ }
+ }
+}
diff --git a/capy/src/main/java/com/jocmp/capy/persistence/FeedRecords.kt b/capy/src/main/java/com/jocmp/capy/persistence/FeedRecords.kt
index 22840e1e8..fad32d43b 100644
--- a/capy/src/main/java/com/jocmp/capy/persistence/FeedRecords.kt
+++ b/capy/src/main/java/com/jocmp/capy/persistence/FeedRecords.kt
@@ -3,6 +3,7 @@ package com.jocmp.capy.persistence
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import com.jocmp.capy.Feed
+import com.jocmp.capy.FeedImportance
import com.jocmp.capy.FeedPriority
import com.jocmp.capy.Folder
import com.jocmp.capy.common.withIOContext
@@ -122,6 +123,13 @@ internal class FeedRecords(private val database: Database) {
database.feedsQueries.toggleAllNotifications(enabled = enabled)
}
+ suspend fun updateImportance(feedID: String, importance: FeedImportance) = withIOContext {
+ database.feedsQueries.updateImportance(
+ importance = importance.storageValue,
+ feedID = feedID,
+ )
+ }
+
suspend fun updateShowUnreadBadge(feedID: String, enabled: Boolean) = withIOContext {
database.feedsQueries.updateShowUnreadBadge(
enabled = enabled,
@@ -186,6 +194,7 @@ internal class FeedRecords(private val database: Database) {
etag: String? = null,
lastModified: String? = null,
conditionalGetRefreshedAt: Long? = null,
+ importance: String? = null,
folderName: String? = "",
expanded: Boolean? = false,
) = Feed(
@@ -205,5 +214,6 @@ internal class FeedRecords(private val database: Database) {
priority = FeedPriority.parse(priority),
showUnreadBadge = showUnreadBadge,
isReadLater = readLater,
+ importance = FeedImportance.parse(importance),
)
}
diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/27_AddImportanceToFeeds.sqm b/capy/src/main/sqldelight/com/jocmp/capy/db/27_AddImportanceToFeeds.sqm
new file mode 100644
index 000000000..e36dc6efa
--- /dev/null
+++ b/capy/src/main/sqldelight/com/jocmp/capy/db/27_AddImportanceToFeeds.sqm
@@ -0,0 +1 @@
+ALTER TABLE feeds ADD COLUMN importance TEXT;
diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/feeds.sq b/capy/src/main/sqldelight/com/jocmp/capy/db/feeds.sq
index 31670b6e8..cbe60b6c7 100644
--- a/capy/src/main/sqldelight/com/jocmp/capy/db/feeds.sq
+++ b/capy/src/main/sqldelight/com/jocmp/capy/db/feeds.sq
@@ -113,6 +113,9 @@ UPDATE feeds SET enable_notifications = :enabled WHERE feeds.id = :feedID;
toggleAllNotifications:
UPDATE feeds SET enable_notifications = :enabled;
+updateImportance:
+UPDATE feeds SET importance = :importance WHERE id = :feedID;
+
updateShowUnreadBadge:
UPDATE feeds SET show_unread_badge = :enabled WHERE id = :feedID;
diff --git a/lite/build.gradle.kts b/lite/build.gradle.kts
new file mode 100644
index 000000000..fe714a26f
--- /dev/null
+++ b/lite/build.gradle.kts
@@ -0,0 +1,117 @@
+import java.util.Properties
+
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("kotlin-parcelize")
+ kotlin("plugin.serialization") version libs.versions.kotlin
+ alias(libs.plugins.compose.compiler)
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
+ }
+}
+
+val secrets = Properties()
+
+if (rootProject.file("secrets.properties").exists()) {
+ secrets.load(rootProject.file("secrets.properties").inputStream())
+}
+
+android {
+ namespace = "com.capyreader.lite"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.capyreader.lite"
+ minSdk = 30
+ targetSdk = 36
+ versionCode = 1
+ versionName = "0.1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ dependenciesInfo {
+ includeInApk = false
+ includeInBundle = false
+ }
+
+ signingConfigs {
+ getByName("debug") {
+ storeFile = file("${project.rootDir}/debug.keystore")
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ debug {
+ applicationIdSuffix = ".debug"
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ excludes += "/META-INF/versions/9/OSGI-INF/MANIFEST.MF"
+ }
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.browser)
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.material)
+ implementation(libs.androidx.material.icons.extended)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.preferences)
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.webkit)
+ implementation(libs.androidx.work.runtime.ktx)
+ implementation(libs.coil.compose)
+ implementation(libs.coil.network.okhttp)
+ implementation(libs.coil.svg)
+ implementation(libs.koin.android)
+ implementation(libs.koin.androidx.compose)
+ implementation(libs.koin.androidx.workmanager)
+ implementation(platform(libs.koin.bom))
+ implementation(libs.koin.core)
+ implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.okhttp.client)
+ implementation(libs.sqldelight.android.driver)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(project(":capy"))
+ implementation(project(":feedfinder"))
+ testImplementation(libs.tests.junit)
+ testImplementation(libs.tests.kotlinx.coroutines)
+ testImplementation(libs.tests.mockk.mockk)
+ testImplementation(libs.tests.robolectric)
+ debugImplementation(libs.androidx.ui.tooling)
+}
diff --git a/lite/proguard-rules.pro b/lite/proguard-rules.pro
new file mode 100644
index 000000000..e374e3e9f
--- /dev/null
+++ b/lite/proguard-rules.pro
@@ -0,0 +1 @@
+# Add project-specific ProGuard rules here.
diff --git a/lite/src/main/AndroidManifest.xml b/lite/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..3bf6104c1
--- /dev/null
+++ b/lite/src/main/AndroidManifest.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lite/src/main/java/com/capyreader/lite/CoreModule.kt b/lite/src/main/java/com/capyreader/lite/CoreModule.kt
new file mode 100644
index 000000000..3e0817a3b
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/CoreModule.kt
@@ -0,0 +1,68 @@
+package com.capyreader.lite
+
+import android.webkit.WebSettings
+import com.capyreader.lite.common.AndroidClientCertManager
+import com.capyreader.lite.common.AndroidDatabaseProvider
+import com.capyreader.lite.common.AppFaviconPolicy
+import com.capyreader.lite.common.SharedPreferenceStoreProvider
+import com.capyreader.lite.preferences.LitePreferences
+import com.capyreader.lite.ui.feeds.FraidycatViewModel
+import com.capyreader.lite.ui.login.LiteLoginViewModel
+import com.jocmp.capy.Account
+import com.jocmp.capy.AccountManager
+import com.jocmp.capy.ClientCertManager
+import com.jocmp.capy.DatabaseProvider
+import com.jocmp.capy.PreferenceStoreProvider
+import com.jocmp.capy.accounts.httpClientBuilder
+import com.jocmp.capy.db.Database
+import okhttp3.OkHttpClient
+import org.koin.android.ext.koin.androidContext
+import org.koin.androidx.viewmodel.dsl.viewModel
+import org.koin.core.context.loadKoinModules
+import org.koin.core.context.unloadKoinModules
+import org.koin.dsl.module
+import java.util.Locale
+
+internal val coreModule = module {
+ single {
+ httpClientBuilder(cachePath = androidContext().cacheDir.toURI()).build()
+ }
+ single { SharedPreferenceStoreProvider(get()) }
+ single { AndroidDatabaseProvider(context = get()) }
+ single { AndroidClientCertManager(context = get()) }
+ single {
+ AccountManager(
+ rootFolder = androidContext().filesDir.toURI(),
+ databaseProvider = get(),
+ cacheDirectory = androidContext().cacheDir.toURI(),
+ preferenceStoreProvider = get(),
+ faviconPolicy = AppFaviconPolicy(get()),
+ clientCertManager = get(),
+ userAgent = WebSettings.getDefaultUserAgent(androidContext()),
+ acceptLanguage = Locale.getDefault().toLanguageTag(),
+ )
+ }
+ single { LitePreferences(get()) }
+ viewModel { LiteLoginViewModel(accountManager = get(), litePreferences = get()) }
+}
+
+internal val accountModule = module {
+ single {
+ get().build(accountID = get().accountID.get())
+ }
+ single {
+ get().findByID(
+ id = get().accountID.get(),
+ database = get()
+ )!!
+ }
+ single { FraidycatViewModel(account = get()) }
+}
+
+fun loadLiteAccountModules() {
+ loadKoinModules(accountModule)
+}
+
+fun unloadLiteAccountModules() {
+ unloadKoinModules(accountModule)
+}
diff --git a/lite/src/main/java/com/capyreader/lite/LiteApplication.kt b/lite/src/main/java/com/capyreader/lite/LiteApplication.kt
new file mode 100644
index 000000000..22f03bc82
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/LiteApplication.kt
@@ -0,0 +1,24 @@
+package com.capyreader.lite
+
+import android.app.Application
+import com.capyreader.lite.preferences.LitePreferences
+import com.google.android.material.color.DynamicColors
+import org.koin.android.ext.android.get
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.context.startKoin
+
+class LiteApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ DynamicColors.applyToActivitiesIfAvailable(this)
+
+ startKoin {
+ androidContext(this@LiteApplication)
+ modules(coreModule)
+ }
+
+ if (get().isLoggedIn) {
+ loadLiteAccountModules()
+ }
+ }
+}
diff --git a/lite/src/main/java/com/capyreader/lite/MainActivity.kt b/lite/src/main/java/com/capyreader/lite/MainActivity.kt
new file mode 100644
index 000000000..e94e8682c
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/MainActivity.kt
@@ -0,0 +1,22 @@
+package com.capyreader.lite
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import com.capyreader.lite.preferences.LitePreferences
+import com.capyreader.lite.theme.CapyLiteTheme
+import com.capyreader.lite.ui.LiteApp
+import org.koin.android.ext.android.inject
+
+class MainActivity : ComponentActivity() {
+ private val preferences by inject()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ CapyLiteTheme {
+ LiteApp(startLoggedIn = preferences.isLoggedIn)
+ }
+ }
+ }
+}
diff --git a/lite/src/main/java/com/capyreader/lite/common/AndroidClientCertManager.kt b/lite/src/main/java/com/capyreader/lite/common/AndroidClientCertManager.kt
new file mode 100644
index 000000000..a4627fe03
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/common/AndroidClientCertManager.kt
@@ -0,0 +1,51 @@
+package com.capyreader.lite.common
+
+import android.content.Context
+import android.security.KeyChain
+import com.jocmp.capy.ClientCertManager
+import okhttp3.OkHttpClient
+import okhttp3.internal.platform.Platform
+import java.net.Socket
+import java.security.Principal
+import java.security.PrivateKey
+import java.security.cert.X509Certificate
+import javax.net.ssl.SSLContext
+import javax.net.ssl.X509KeyManager
+
+class AndroidClientCertManager(private val context: Context) : ClientCertManager {
+ override fun configure(builder: OkHttpClient.Builder, certAlias: String): OkHttpClient.Builder {
+ val clientKeyManager = object : X509KeyManager {
+ override fun getClientAliases(keyType: String?, issuers: Array?) =
+ throw UnsupportedOperationException("getClientAliases")
+
+ override fun chooseClientAlias(
+ keyType: Array?,
+ issuers: Array?,
+ socket: Socket?
+ ) = certAlias
+
+ override fun getServerAliases(keyType: String?, issuers: Array?) =
+ throw UnsupportedOperationException("getServerAliases")
+
+ override fun chooseServerAlias(
+ keyType: String?,
+ issuers: Array?,
+ socket: Socket?
+ ) = throw UnsupportedOperationException("chooseServerAlias")
+
+ override fun getCertificateChain(alias: String?): Array? {
+ return if (alias == certAlias) KeyChain.getCertificateChain(context, certAlias) else null
+ }
+
+ override fun getPrivateKey(alias: String?): PrivateKey? {
+ return if (alias == certAlias) KeyChain.getPrivateKey(context, certAlias) else null
+ }
+ }
+
+ val sslContext = SSLContext.getInstance("TLS")
+ val trustManager = Platform.get().platformTrustManager()
+ sslContext.init(arrayOf(clientKeyManager), arrayOf(trustManager), null)
+
+ return builder.sslSocketFactory(sslContext.socketFactory, trustManager)
+ }
+}
diff --git a/lite/src/main/java/com/capyreader/lite/common/AndroidDatabaseProvider.kt b/lite/src/main/java/com/capyreader/lite/common/AndroidDatabaseProvider.kt
new file mode 100644
index 000000000..f1db11b63
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/common/AndroidDatabaseProvider.kt
@@ -0,0 +1,47 @@
+package com.capyreader.lite.common
+
+import android.content.Context
+import androidx.sqlite.db.SupportSQLiteDatabase
+import app.cash.sqldelight.driver.android.AndroidSqliteDriver
+import com.jocmp.capy.DatabaseProvider
+import com.jocmp.capy.db.Database
+import com.jocmp.capy.logging.CapyLog
+
+class AndroidDatabaseProvider(private val context: Context) : DatabaseProvider {
+ override fun build(accountID: String): Database {
+ val driver = AndroidSqliteDriver(
+ Database.Schema,
+ context,
+ databaseName(accountID),
+ windowSizeBytes = 8 * 1024 * 1024,
+ /**
+ * - https://developer.android.com/topic/performance/sqlite-performance-best-practices#enable-write-ahead
+ */
+ callback = object : AndroidSqliteDriver.Callback(Database.Schema) {
+ override fun onConfigure(db: SupportSQLiteDatabase) {
+ super.onConfigure(db)
+ tryEnableWriteAheadLogging(db)
+ }
+
+ private fun tryEnableWriteAheadLogging(db: SupportSQLiteDatabase) {
+ try {
+ db.enableWriteAheadLogging()
+ db.execSQL("PRAGMA synchronous = NORMAL")
+ } catch (e: Exception) {
+ CapyLog.error("androiddb", e)
+ }
+ }
+ }
+ )
+
+ return Database(driver)
+ }
+
+ override fun delete(accountID: String) {
+ context.deleteDatabase(databaseName(accountID))
+ }
+
+ private fun databaseName(accountID: String): String {
+ return "articles_$accountID"
+ }
+}
diff --git a/lite/src/main/java/com/capyreader/lite/common/AppFaviconPolicy.kt b/lite/src/main/java/com/capyreader/lite/common/AppFaviconPolicy.kt
new file mode 100644
index 000000000..e930d5d9f
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/common/AppFaviconPolicy.kt
@@ -0,0 +1,21 @@
+package com.capyreader.lite.common
+
+import android.content.Context
+import coil3.imageLoader
+import coil3.request.ImageRequest
+import com.jocmp.capy.accounts.FaviconPolicy
+
+class AppFaviconPolicy(private val context: Context) : FaviconPolicy {
+ override suspend fun isValid(url: String?): Boolean {
+ url ?: return false
+
+ val result = context.imageLoader
+ .execute(
+ ImageRequest.Builder(context)
+ .data(url)
+ .build()
+ )
+
+ return result.image != null
+ }
+}
diff --git a/lite/src/main/java/com/capyreader/lite/common/SharedPreferenceStoreProvider.kt b/lite/src/main/java/com/capyreader/lite/common/SharedPreferenceStoreProvider.kt
new file mode 100644
index 000000000..67c4a1b62
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/common/SharedPreferenceStoreProvider.kt
@@ -0,0 +1,35 @@
+package com.capyreader.lite.common
+
+import android.content.Context
+import android.content.Context.MODE_PRIVATE
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import com.jocmp.capy.AccountPreferences
+import com.jocmp.capy.PreferenceStoreProvider
+import com.capyreader.lite.preferences.AndroidPreferenceStore
+
+
+class SharedPreferenceStoreProvider(
+ private val context: Context
+) : PreferenceStoreProvider {
+ override fun build(accountID: String): AccountPreferences {
+ return AccountPreferences(
+ AndroidPreferenceStore(
+ buildPreferences(context, accountID)
+ )
+ )
+ }
+
+ override fun delete(accountID: String) {
+ val preferences = buildPreferences(context, accountID)
+
+ preferences.edit(commit = true) {
+ clear()
+ }
+ }
+}
+
+private fun buildPreferences(context: Context, accountID: String) =
+ context.getSharedPreferences(accountPrefs(accountID), MODE_PRIVATE)
+
+private fun accountPrefs(accountID: String) = "account_$accountID"
diff --git a/lite/src/main/java/com/capyreader/lite/preferences/AndroidPreference.kt b/lite/src/main/java/com/capyreader/lite/preferences/AndroidPreference.kt
new file mode 100644
index 000000000..7c5f70a79
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/preferences/AndroidPreference.kt
@@ -0,0 +1,176 @@
+package com.capyreader.lite.preferences
+
+import android.content.SharedPreferences
+import android.content.SharedPreferences.Editor
+import androidx.core.content.edit
+import com.jocmp.capy.preferences.Preference
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+
+sealed class AndroidPreference(
+ private val preferences: SharedPreferences,
+ private val keyFlow: Flow,
+ private val key: String,
+ private val defaultValue: T,
+) : Preference {
+
+ abstract fun read(preferences: SharedPreferences, key: String, defaultValue: T): T
+
+ abstract fun write(key: String, value: T): Editor.() -> Unit
+
+ override fun key(): String {
+ return key
+ }
+
+ override fun get(): T {
+ return read(preferences, key, defaultValue)
+ }
+
+ override fun set(value: T) {
+ preferences.edit(action = write(key, value))
+ }
+
+ override fun isSet(): Boolean {
+ return preferences.contains(key)
+ }
+
+ override fun delete() {
+ preferences.edit {
+ remove(key)
+ }
+ }
+
+ override fun defaultValue(): T {
+ return defaultValue
+ }
+
+ override fun changes(): Flow {
+ return keyFlow
+ .filter { it == key || it == null }
+ .onStart { emit("ignition") }
+ .map { get() }
+ .conflate()
+ }
+
+ override fun stateIn(scope: CoroutineScope): StateFlow {
+ return changes().stateIn(scope, SharingStarted.Eagerly, get())
+ }
+
+ class StringPrimitive(
+ preferences: SharedPreferences,
+ keyFlow: Flow,
+ key: String,
+ defaultValue: String,
+ ) : AndroidPreference(preferences, keyFlow, key, defaultValue) {
+ override fun read(preferences: SharedPreferences, key: String, defaultValue: String): String {
+ return preferences.getString(key, defaultValue) ?: defaultValue
+ }
+
+ override fun write(key: String, value: String): Editor.() -> Unit = {
+ putString(key, value)
+ }
+ }
+
+ class LongPrimitive(
+ preferences: SharedPreferences,
+ keyFlow: Flow,
+ key: String,
+ defaultValue: Long,
+ ) : AndroidPreference(preferences, keyFlow, key, defaultValue) {
+ override fun read(preferences: SharedPreferences, key: String, defaultValue: Long): Long {
+ return preferences.getLong(key, defaultValue)
+ }
+
+ override fun write(key: String, value: Long): Editor.() -> Unit = {
+ putLong(key, value)
+ }
+ }
+
+ class IntPrimitive(
+ preferences: SharedPreferences,
+ keyFlow: Flow,
+ key: String,
+ defaultValue: Int,
+ ) : AndroidPreference(preferences, keyFlow, key, defaultValue) {
+ override fun read(preferences: SharedPreferences, key: String, defaultValue: Int): Int {
+ return preferences.getInt(key, defaultValue)
+ }
+
+ override fun write(key: String, value: Int): Editor.() -> Unit = {
+ putInt(key, value)
+ }
+ }
+
+ class FloatPrimitive(
+ preferences: SharedPreferences,
+ keyFlow: Flow,
+ key: String,
+ defaultValue: Float,
+ ) : AndroidPreference(preferences, keyFlow, key, defaultValue) {
+ override fun read(preferences: SharedPreferences, key: String, defaultValue: Float): Float {
+ return preferences.getFloat(key, defaultValue)
+ }
+
+ override fun write(key: String, value: Float): Editor.() -> Unit = {
+ putFloat(key, value)
+ }
+ }
+
+ class BooleanPrimitive(
+ preferences: SharedPreferences,
+ keyFlow: Flow,
+ key: String,
+ defaultValue: Boolean,
+ ) : AndroidPreference(preferences, keyFlow, key, defaultValue) {
+ override fun read(preferences: SharedPreferences, key: String, defaultValue: Boolean): Boolean {
+ return preferences.getBoolean(key, defaultValue)
+ }
+
+ override fun write(key: String, value: Boolean): Editor.() -> Unit = {
+ putBoolean(key, value)
+ }
+ }
+
+ class StringSetPrimitive(
+ preferences: SharedPreferences,
+ keyFlow: Flow,
+ key: String,
+ defaultValue: Set,
+ ) : AndroidPreference>(preferences, keyFlow, key, defaultValue) {
+ override fun read(preferences: SharedPreferences, key: String, defaultValue: Set): Set {
+ return preferences.getStringSet(key, defaultValue) ?: defaultValue
+ }
+
+ override fun write(key: String, value: Set): Editor.() -> Unit = {
+ putStringSet(key, value)
+ }
+ }
+
+ class Object(
+ preferences: SharedPreferences,
+ keyFlow: Flow,
+ key: String,
+ defaultValue: T,
+ val serializer: (T) -> String,
+ val deserializer: (String) -> T,
+ ) : AndroidPreference(preferences, keyFlow, key, defaultValue) {
+ override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T {
+ return try {
+ preferences.getString(key, null)?.let(deserializer) ?: defaultValue
+ } catch (e: Exception) {
+ defaultValue
+ }
+ }
+
+ override fun write(key: String, value: T): Editor.() -> Unit = {
+ putString(key, serializer(value))
+ }
+ }
+}
diff --git a/lite/src/main/java/com/capyreader/lite/preferences/AndroidPreferenceStore.kt b/lite/src/main/java/com/capyreader/lite/preferences/AndroidPreferenceStore.kt
new file mode 100644
index 000000000..1bdb4ad3f
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/preferences/AndroidPreferenceStore.kt
@@ -0,0 +1,67 @@
+package com.capyreader.lite.preferences
+
+import android.content.SharedPreferences
+import com.jocmp.capy.preferences.Preference
+import com.jocmp.capy.preferences.PreferenceStore
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.callbackFlow
+
+class AndroidPreferenceStore(
+ private val sharedPreferences: SharedPreferences,
+) : PreferenceStore {
+ private val keyFlow = sharedPreferences.keyFlow
+
+ override fun getString(key: String, defaultValue: String): Preference {
+ return AndroidPreference.StringPrimitive(sharedPreferences, keyFlow, key, defaultValue)
+ }
+
+ override fun getLong(key: String, defaultValue: Long): Preference {
+ return AndroidPreference.LongPrimitive(sharedPreferences, keyFlow, key, defaultValue)
+ }
+
+ override fun getInt(key: String, defaultValue: Int): Preference {
+ return AndroidPreference.IntPrimitive(sharedPreferences, keyFlow, key, defaultValue)
+ }
+
+ override fun getFloat(key: String, defaultValue: Float): Preference {
+ return AndroidPreference.FloatPrimitive(sharedPreferences, keyFlow, key, defaultValue)
+ }
+
+ override fun getBoolean(key: String, defaultValue: Boolean): Preference {
+ return AndroidPreference.BooleanPrimitive(sharedPreferences, keyFlow, key, defaultValue)
+ }
+
+ override fun getStringSet(key: String, defaultValue: Set): Preference> {
+ return AndroidPreference.StringSetPrimitive(sharedPreferences, keyFlow, key, defaultValue)
+ }
+
+ override fun getObject(
+ key: String,
+ defaultValue: T,
+ serializer: (T) -> String,
+ deserializer: (String) -> T,
+ ): Preference {
+ return AndroidPreference.Object(
+ preferences = sharedPreferences,
+ keyFlow = keyFlow,
+ key = key,
+ defaultValue = defaultValue,
+ serializer = serializer,
+ deserializer = deserializer,
+ )
+ }
+
+ override fun clearAll() {
+ sharedPreferences.edit().clear().apply()
+ }
+}
+
+private val SharedPreferences.keyFlow
+ get() = callbackFlow {
+ val listener =
+ SharedPreferences.OnSharedPreferenceChangeListener { _, key: String? -> trySend(key) }
+ registerOnSharedPreferenceChangeListener(listener)
+ awaitClose {
+ unregisterOnSharedPreferenceChangeListener(listener)
+ }
+ }
diff --git a/lite/src/main/java/com/capyreader/lite/preferences/LitePreferences.kt b/lite/src/main/java/com/capyreader/lite/preferences/LitePreferences.kt
new file mode 100644
index 000000000..171ca96db
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/preferences/LitePreferences.kt
@@ -0,0 +1,16 @@
+package com.capyreader.lite.preferences
+
+import android.content.Context
+import androidx.preference.PreferenceManager
+import com.jocmp.capy.preferences.PreferenceStore
+
+class LitePreferences(context: Context) {
+ private val preferenceStore: PreferenceStore = AndroidPreferenceStore(
+ sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
+ )
+
+ val accountID = preferenceStore.getString("account_id", "")
+
+ val isLoggedIn: Boolean
+ get() = accountID.get().isNotBlank()
+}
diff --git a/lite/src/main/java/com/capyreader/lite/theme/Theme.kt b/lite/src/main/java/com/capyreader/lite/theme/Theme.kt
new file mode 100644
index 000000000..916597d8d
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/theme/Theme.kt
@@ -0,0 +1,16 @@
+package com.capyreader.lite.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+fun CapyLiteTheme(content: @Composable () -> Unit) {
+ val context = LocalContext.current
+ val dark = isSystemInDarkTheme()
+ val colors = if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ MaterialTheme(colorScheme = colors, content = content)
+}
diff --git a/lite/src/main/java/com/capyreader/lite/ui/LiteApp.kt b/lite/src/main/java/com/capyreader/lite/ui/LiteApp.kt
new file mode 100644
index 000000000..68a0f0561
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/ui/LiteApp.kt
@@ -0,0 +1,49 @@
+package com.capyreader.lite.ui
+
+import androidx.compose.runtime.Composable
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.capyreader.lite.loadLiteAccountModules
+import com.capyreader.lite.ui.articles.ArticlePagerScreen
+import com.capyreader.lite.ui.feeds.FraidycatScreen
+import com.capyreader.lite.ui.feeds.FraidycatViewModel
+import com.capyreader.lite.ui.login.LiteLoginScreen
+import org.koin.compose.koinInject
+
+private object Routes {
+ const val LOGIN = "login"
+ const val FEEDS = "feeds"
+ const val ARTICLES = "articles"
+}
+
+@Composable
+fun LiteApp(startLoggedIn: Boolean) {
+ val nav = rememberNavController()
+ val start = if (startLoggedIn) Routes.FEEDS else Routes.LOGIN
+
+ NavHost(navController = nav, startDestination = start) {
+ composable(Routes.LOGIN) {
+ LiteLoginScreen(
+ onAuthenticated = {
+ loadLiteAccountModules()
+ nav.navigate(Routes.FEEDS) {
+ popUpTo(Routes.LOGIN) { inclusive = true }
+ }
+ },
+ )
+ }
+ composable(Routes.FEEDS) {
+ val vm = koinInject()
+ FraidycatScreen(
+ onSelectFeed = { id ->
+ vm.selectFeed(id)
+ nav.navigate(Routes.ARTICLES)
+ },
+ )
+ }
+ composable(Routes.ARTICLES) {
+ ArticlePagerScreen(onBack = { nav.popBackStack() })
+ }
+ }
+}
diff --git a/lite/src/main/java/com/capyreader/lite/ui/articles/ArticlePagerScreen.kt b/lite/src/main/java/com/capyreader/lite/ui/articles/ArticlePagerScreen.kt
new file mode 100644
index 000000000..bcd647abe
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/ui/articles/ArticlePagerScreen.kt
@@ -0,0 +1,108 @@
+package com.capyreader.lite.ui.articles
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.capyreader.lite.R
+import com.capyreader.lite.ui.feeds.FraidycatViewModel
+import com.jocmp.capy.Article
+import org.koin.compose.koinInject
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ArticlePagerScreen(
+ onBack: () -> Unit,
+ viewModel: FraidycatViewModel = koinInject(),
+) {
+ val articles by viewModel.selectedArticles.collectAsStateWithLifecycle()
+ val pagerState = rememberPagerState(pageCount = { articles.size })
+
+ LaunchedEffect(pagerState.currentPage, articles) {
+ articles.getOrNull(pagerState.currentPage)?.let {
+ // Follow read state but do not filter the view.
+ if (!it.read) viewModel.markRead(it.id)
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = articles
+ .getOrNull(pagerState.currentPage)
+ ?.title.orEmpty(),
+ maxLines = 1,
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+ }
+ },
+ )
+ },
+ ) { padding ->
+ if (articles.isEmpty()) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(stringResource(R.string.article_empty))
+ }
+ return@Scaffold
+ }
+
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) { page ->
+ ArticlePage(article = articles[page])
+ }
+ }
+}
+
+@Composable
+private fun ArticlePage(article: Article) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(text = article.title, style = MaterialTheme.typography.headlineSmall)
+ article.author?.takeIf { it.isNotBlank() }?.let {
+ Text(text = it, style = MaterialTheme.typography.labelMedium)
+ }
+ // TODO: render full content / sticky full content via WebView.
+ Text(text = article.summary)
+ }
+}
diff --git a/lite/src/main/java/com/capyreader/lite/ui/feeds/FraidycatScreen.kt b/lite/src/main/java/com/capyreader/lite/ui/feeds/FraidycatScreen.kt
new file mode 100644
index 000000000..1ef90d079
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/ui/feeds/FraidycatScreen.kt
@@ -0,0 +1,105 @@
+package com.capyreader.lite.ui.feeds
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+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.lifecycle.compose.collectAsStateWithLifecycle
+import com.capyreader.lite.R
+import com.jocmp.capy.FeedImportance
+import org.koin.compose.koinInject
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FraidycatScreen(
+ onSelectFeed: (String) -> Unit,
+ viewModel: FraidycatViewModel = koinInject(),
+) {
+ val buckets by viewModel.buckets.collectAsStateWithLifecycle()
+
+ Scaffold(
+ topBar = { TopAppBar(title = { Text(stringResource(R.string.app_name)) }) },
+ ) { padding ->
+ if (buckets.isEmpty()) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(stringResource(R.string.feed_empty))
+ }
+ return@Scaffold
+ }
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ buckets.forEach { bucket ->
+ item(key = "h-${bucket.importance.name}") {
+ Column(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = bucket.importance.label(),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ HorizontalDivider()
+ }
+ }
+ items(bucket.feeds, key = { it.id }) { feed ->
+ ListItem(
+ headlineContent = { Text(feed.title) },
+ supportingContent = if (feed.siteURL.isNotBlank()) {
+ { Text(feed.siteURL) }
+ } else null,
+ modifier = Modifier
+ .fillMaxSize()
+ .clickable { onSelectFeed(feed.id) }
+ .padding(horizontal = 0.dp),
+ leadingContent = {
+ // Importance indicator dot could go here.
+ },
+ trailingContent = {
+ if (feed.count > 0) {
+ Text(feed.count.toString())
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun FeedImportance.label(): String = when (this) {
+ FeedImportance.REAL_TIME -> stringResource(R.string.importance_real_time)
+ FeedImportance.DAILY -> stringResource(R.string.importance_daily)
+ FeedImportance.NORMAL -> stringResource(R.string.importance_normal)
+ FeedImportance.WEEKLY -> stringResource(R.string.importance_weekly)
+ FeedImportance.MONTHLY -> stringResource(R.string.importance_monthly)
+ FeedImportance.YEARLY -> stringResource(R.string.importance_yearly)
+}
diff --git a/lite/src/main/java/com/capyreader/lite/ui/feeds/FraidycatViewModel.kt b/lite/src/main/java/com/capyreader/lite/ui/feeds/FraidycatViewModel.kt
new file mode 100644
index 000000000..1aec585be
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/ui/feeds/FraidycatViewModel.kt
@@ -0,0 +1,73 @@
+package com.capyreader.lite.ui.feeds
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.jocmp.capy.Account
+import com.jocmp.capy.Article
+import com.jocmp.capy.Feed
+import com.jocmp.capy.FeedImportance
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+data class ImportanceBucket(
+ val importance: FeedImportance,
+ val feeds: List,
+)
+
+class FraidycatViewModel(private val account: Account) : ViewModel() {
+ val buckets: StateFlow> = account.allFeeds
+ .map { groupByImportance(it) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
+ private val _selectedFeedID = MutableStateFlow(null)
+ val selectedFeedID: StateFlow = _selectedFeedID
+
+ private val _selectedArticles = MutableStateFlow>(emptyList())
+ val selectedArticles: StateFlow> = _selectedArticles
+
+ fun selectFeed(id: String) {
+ _selectedFeedID.value = id
+ viewModelScope.launch(Dispatchers.IO) {
+ // TODO: list articles for feed via ArticleRecords; Fraidycat philosophy:
+ // include read + unread, sorted newest-first, no status filter.
+ _selectedArticles.value = emptyList()
+ }
+ }
+
+ fun clearSelection() {
+ _selectedFeedID.value = null
+ _selectedArticles.value = emptyList()
+ }
+
+ fun setImportance(feedID: String, importance: FeedImportance) {
+ viewModelScope.launch(Dispatchers.IO) {
+ account.updateFeedImportance(feedID, importance)
+ }
+ }
+
+ fun markRead(articleID: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ account.markRead(articleID)
+ }
+ }
+
+ private fun groupByImportance(feeds: List): List {
+ val order = listOf(
+ FeedImportance.REAL_TIME,
+ FeedImportance.DAILY,
+ FeedImportance.NORMAL,
+ FeedImportance.WEEKLY,
+ FeedImportance.MONTHLY,
+ FeedImportance.YEARLY,
+ )
+ val grouped = feeds.groupBy { it.importance }
+ return order.mapNotNull { importance ->
+ grouped[importance]?.let { ImportanceBucket(importance, it) }
+ }
+ }
+}
diff --git a/lite/src/main/java/com/capyreader/lite/ui/login/LiteLoginScreen.kt b/lite/src/main/java/com/capyreader/lite/ui/login/LiteLoginScreen.kt
new file mode 100644
index 000000000..6fb3c32be
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/ui/login/LiteLoginScreen.kt
@@ -0,0 +1,119 @@
+package com.capyreader.lite.ui.login
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material3.Button
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+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.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.capyreader.lite.R
+import com.jocmp.capy.accounts.Source
+import org.koin.androidx.compose.koinViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LiteLoginScreen(
+ onAuthenticated: () -> Unit,
+ viewModel: LiteLoginViewModel = koinViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ Scaffold(
+ topBar = { TopAppBar(title = { Text(stringResource(R.string.login_title)) }) },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .padding(horizontal = 16.dp)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ SourceDropdown(
+ source = state.source,
+ onSelect = viewModel::setSource,
+ )
+
+ if (state.source.requiresUsername) {
+ OutlinedTextField(
+ value = state.username,
+ onValueChange = viewModel::setUsername,
+ label = { Text(stringResource(R.string.login_username)) },
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+
+ if (state.source != Source.LOCAL) {
+ OutlinedTextField(
+ value = state.password,
+ onValueChange = viewModel::setPassword,
+ label = { Text(stringResource(R.string.login_password)) },
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+
+ if (state.source.hasCustomURL) {
+ OutlinedTextField(
+ value = state.serverURL,
+ onValueChange = viewModel::setServerURL,
+ label = { Text(stringResource(R.string.login_server_url)) },
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+
+ Button(
+ onClick = { viewModel.submit(onAuthenticated) },
+ enabled = !state.submitting,
+ ) {
+ Text(stringResource(R.string.login_submit))
+ }
+ }
+ }
+}
+
+@Composable
+private fun SourceDropdown(
+ source: Source,
+ onSelect: (Source) -> Unit,
+) {
+ var expanded by remember { mutableStateOf(false) }
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ TextButton(onClick = { expanded = true }) {
+ Text(source.name)
+ Icon(Icons.Default.ArrowDropDown, contentDescription = null)
+ }
+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ Source.entries.forEach { entry ->
+ DropdownMenuItem(
+ text = { Text(entry.name) },
+ onClick = {
+ onSelect(entry)
+ expanded = false
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/lite/src/main/java/com/capyreader/lite/ui/login/LiteLoginViewModel.kt b/lite/src/main/java/com/capyreader/lite/ui/login/LiteLoginViewModel.kt
new file mode 100644
index 000000000..5c172a390
--- /dev/null
+++ b/lite/src/main/java/com/capyreader/lite/ui/login/LiteLoginViewModel.kt
@@ -0,0 +1,55 @@
+package com.capyreader.lite.ui.login
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.capyreader.lite.preferences.LitePreferences
+import com.jocmp.capy.AccountManager
+import com.jocmp.capy.accounts.Source
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+data class LiteLoginState(
+ val source: Source = Source.LOCAL,
+ val username: String = "",
+ val password: String = "",
+ val serverURL: String = "",
+ val submitting: Boolean = false,
+ val error: String? = null,
+)
+
+class LiteLoginViewModel(
+ private val accountManager: AccountManager,
+ private val litePreferences: LitePreferences,
+) : ViewModel() {
+ private val _state = MutableStateFlow(LiteLoginState())
+ val state: StateFlow = _state.asStateFlow()
+
+ fun setSource(source: Source) = _state.update { it.copy(source = source) }
+ fun setUsername(value: String) = _state.update { it.copy(username = value) }
+ fun setPassword(value: String) = _state.update { it.copy(password = value) }
+ fun setServerURL(value: String) = _state.update { it.copy(serverURL = value) }
+
+ fun submit(onSuccess: () -> Unit) {
+ val s = _state.value
+ _state.update { it.copy(submitting = true, error = null) }
+ viewModelScope.launch(Dispatchers.IO) {
+ val accountID = if (s.source == Source.LOCAL) {
+ accountManager.createAccount(source = Source.LOCAL)
+ } else {
+ accountManager.createAccount(
+ username = s.username,
+ password = s.password,
+ url = s.serverURL,
+ source = s.source,
+ )
+ }
+ litePreferences.accountID.set(accountID)
+ _state.update { it.copy(submitting = false) }
+ onSuccess()
+ }
+ }
+}
diff --git a/lite/src/main/res/drawable/ic_launcher_foreground.xml b/lite/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 000000000..474837372
--- /dev/null
+++ b/lite/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/lite/src/main/res/drawable/ic_launcher_foreground_full_color.xml b/lite/src/main/res/drawable/ic_launcher_foreground_full_color.xml
new file mode 100644
index 000000000..e4d94d7bd
--- /dev/null
+++ b/lite/src/main/res/drawable/ic_launcher_foreground_full_color.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lite/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/lite/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..a484e5633
--- /dev/null
+++ b/lite/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/lite/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/lite/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 000000000..098c36a46
--- /dev/null
+++ b/lite/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/lite/src/main/res/values/ic_launcher_background.xml b/lite/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 000000000..c0b728c3f
--- /dev/null
+++ b/lite/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #E2D4B0
+
\ No newline at end of file
diff --git a/lite/src/main/res/values/strings.xml b/lite/src/main/res/values/strings.xml
new file mode 100644
index 000000000..7dc5f4ee0
--- /dev/null
+++ b/lite/src/main/res/values/strings.xml
@@ -0,0 +1,21 @@
+
+
+ Capy Lite
+
+ Sign in
+ Email or username
+ Password
+ Server URL
+ Sign in
+ Account type
+
+ Real-time
+ Daily
+ Normal
+ Weekly
+ Monthly
+ Yearly
+
+ No feeds yet
+ No articles in this feed
+
diff --git a/lite/src/main/res/values/themes.xml b/lite/src/main/res/values/themes.xml
new file mode 100644
index 000000000..a941bcb8a
--- /dev/null
+++ b/lite/src/main/res/values/themes.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 5c5d1443e..07f7b7088 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -31,6 +31,7 @@ rootProject.name = "Capy Reader"
// }
include(":app")
+include(":lite")
include(":feedbinclient")
include(":feedfinder")
include(":capy")