From 0ecf7be330071805cdfd5e805e3d027f1cb8714a Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sat, 16 May 2026 11:51:55 -0500 Subject: [PATCH] FraidyCap --- capy/src/main/java/com/jocmp/capy/Account.kt | 4 + capy/src/main/java/com/jocmp/capy/Feed.kt | 1 + .../java/com/jocmp/capy/FeedImportance.kt | 25 +++ .../com/jocmp/capy/persistence/FeedRecords.kt | 10 + .../jocmp/capy/db/27_AddImportanceToFeeds.sqm | 1 + .../sqldelight/com/jocmp/capy/db/feeds.sq | 3 + lite/build.gradle.kts | 117 ++++++++++++ lite/proguard-rules.pro | 1 + lite/src/main/AndroidManifest.xml | 38 ++++ .../java/com/capyreader/lite/CoreModule.kt | 68 +++++++ .../com/capyreader/lite/LiteApplication.kt | 24 +++ .../java/com/capyreader/lite/MainActivity.kt | 22 +++ .../lite/common/AndroidClientCertManager.kt | 51 +++++ .../lite/common/AndroidDatabaseProvider.kt | 47 +++++ .../lite/common/AppFaviconPolicy.kt | 21 +++ .../common/SharedPreferenceStoreProvider.kt | 35 ++++ .../lite/preferences/AndroidPreference.kt | 176 ++++++++++++++++++ .../preferences/AndroidPreferenceStore.kt | 67 +++++++ .../lite/preferences/LitePreferences.kt | 16 ++ .../java/com/capyreader/lite/theme/Theme.kt | 16 ++ .../java/com/capyreader/lite/ui/LiteApp.kt | 49 +++++ .../lite/ui/articles/ArticlePagerScreen.kt | 108 +++++++++++ .../lite/ui/feeds/FraidycatScreen.kt | 105 +++++++++++ .../lite/ui/feeds/FraidycatViewModel.kt | 73 ++++++++ .../lite/ui/login/LiteLoginScreen.kt | 119 ++++++++++++ .../lite/ui/login/LiteLoginViewModel.kt | 55 ++++++ .../res/drawable/ic_launcher_foreground.xml | 14 ++ .../ic_launcher_foreground_full_color.xml | 43 +++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 7 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 7 + .../res/values/ic_launcher_background.xml | 4 + lite/src/main/res/values/strings.xml | 21 +++ lite/src/main/res/values/themes.xml | 6 + settings.gradle.kts | 1 + 34 files changed, 1355 insertions(+) create mode 100644 capy/src/main/java/com/jocmp/capy/FeedImportance.kt create mode 100644 capy/src/main/sqldelight/com/jocmp/capy/db/27_AddImportanceToFeeds.sqm create mode 100644 lite/build.gradle.kts create mode 100644 lite/proguard-rules.pro create mode 100644 lite/src/main/AndroidManifest.xml create mode 100644 lite/src/main/java/com/capyreader/lite/CoreModule.kt create mode 100644 lite/src/main/java/com/capyreader/lite/LiteApplication.kt create mode 100644 lite/src/main/java/com/capyreader/lite/MainActivity.kt create mode 100644 lite/src/main/java/com/capyreader/lite/common/AndroidClientCertManager.kt create mode 100644 lite/src/main/java/com/capyreader/lite/common/AndroidDatabaseProvider.kt create mode 100644 lite/src/main/java/com/capyreader/lite/common/AppFaviconPolicy.kt create mode 100644 lite/src/main/java/com/capyreader/lite/common/SharedPreferenceStoreProvider.kt create mode 100644 lite/src/main/java/com/capyreader/lite/preferences/AndroidPreference.kt create mode 100644 lite/src/main/java/com/capyreader/lite/preferences/AndroidPreferenceStore.kt create mode 100644 lite/src/main/java/com/capyreader/lite/preferences/LitePreferences.kt create mode 100644 lite/src/main/java/com/capyreader/lite/theme/Theme.kt create mode 100644 lite/src/main/java/com/capyreader/lite/ui/LiteApp.kt create mode 100644 lite/src/main/java/com/capyreader/lite/ui/articles/ArticlePagerScreen.kt create mode 100644 lite/src/main/java/com/capyreader/lite/ui/feeds/FraidycatScreen.kt create mode 100644 lite/src/main/java/com/capyreader/lite/ui/feeds/FraidycatViewModel.kt create mode 100644 lite/src/main/java/com/capyreader/lite/ui/login/LiteLoginScreen.kt create mode 100644 lite/src/main/java/com/capyreader/lite/ui/login/LiteLoginViewModel.kt create mode 100644 lite/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 lite/src/main/res/drawable/ic_launcher_foreground_full_color.xml create mode 100644 lite/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 lite/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 lite/src/main/res/values/ic_launcher_background.xml create mode 100644 lite/src/main/res/values/strings.xml create mode 100644 lite/src/main/res/values/themes.xml 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")