feat: implement cross-platform features and UI integration

- iOS: Add BackgroundSyncService, SyncScheduler, SyncWorker, BookmarkViewModel, FeedViewModel
- iOS: Add BackgroundSyncService, SyncScheduler, SyncWorker services
- Linux: Add settings-store.vala, State.vala signals, view widgets (FeedList, FeedDetail, AddFeed, Search, Settings, Bookmark)
- Linux: Add bookmark-store.vala, bookmark vala model, search-service.vala
- Android: Add NotificationService, NotificationManager, NotificationPreferencesStore
- Android: Add BookmarkDao, BookmarkRepository, SettingsStore
- Add unit tests for iOS, Android, Linux
- Add integration tests
- Add performance benchmarks
- Update tasks and documentation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-03-30 23:06:12 -04:00
parent 6191458730
commit 14efe072fa
98 changed files with 11262 additions and 109 deletions

View File

@@ -3,6 +3,7 @@ package com.rssuper.database
import android.content.Context
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Migration
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@@ -10,10 +11,14 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import com.rssuper.converters.DateConverter
import com.rssuper.converters.FeedItemListConverter
import com.rssuper.converters.StringListConverter
import com.rssuper.database.daos.BookmarkDao
import com.rssuper.database.daos.FeedItemDao
import com.rssuper.database.daos.NotificationPreferencesDao
import com.rssuper.database.daos.SearchHistoryDao
import com.rssuper.database.daos.SubscriptionDao
import com.rssuper.database.entities.BookmarkEntity
import com.rssuper.database.entities.FeedItemEntity
import com.rssuper.database.entities.NotificationPreferencesEntity
import com.rssuper.database.entities.SearchHistoryEntity
import com.rssuper.database.entities.SubscriptionEntity
import kotlinx.coroutines.CoroutineScope
@@ -25,9 +30,11 @@ import java.util.Date
entities = [
SubscriptionEntity::class,
FeedItemEntity::class,
SearchHistoryEntity::class
SearchHistoryEntity::class,
BookmarkEntity::class,
NotificationPreferencesEntity::class
],
version = 1,
version = 2,
exportSchema = true
)
@TypeConverters(DateConverter::class, StringListConverter::class, FeedItemListConverter::class)
@@ -36,11 +43,35 @@ abstract class RssDatabase : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao
abstract fun feedItemDao(): FeedItemDao
abstract fun searchHistoryDao(): SearchHistoryDao
abstract fun bookmarkDao(): BookmarkDao
abstract fun notificationPreferencesDao(): NotificationPreferencesDao
companion object {
@Volatile
private var INSTANCE: RssDatabase? = null
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT NOT NULL,
feedItemId TEXT NOT NULL,
title TEXT NOT NULL,
link TEXT,
description TEXT,
content TEXT,
createdAt INTEGER NOT NULL,
tags TEXT,
PRIMARY KEY (id),
FOREIGN KEY (feedItemId) REFERENCES feed_items(id) ON DELETE CASCADE
)
""".trimIndent())
db.execSQL("""
CREATE INDEX IF NOT EXISTS idx_bookmarks_feedItemId ON bookmarks(feedItemId)
""".trimIndent())
}
}
fun getDatabase(context: Context): RssDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
@@ -48,6 +79,7 @@ abstract class RssDatabase : RoomDatabase() {
RssDatabase::class.java,
"rss_database"
)
.addMigrations(MIGRATION_1_2)
.addCallback(DatabaseCallback())
.build()
INSTANCE = instance

View File

@@ -20,7 +20,7 @@ interface BookmarkDao {
@Query("SELECT * FROM bookmarks WHERE feedItemId = :feedItemId")
suspend fun getBookmarkByFeedItemId(feedItemId: String): BookmarkEntity?
@Query("SELECT * FROM bookmarks WHERE tags LIKE '%' || :tag || '%' ORDER BY createdAt DESC")
@Query("SELECT * FROM bookmarks WHERE tags LIKE :tagPattern ORDER BY createdAt DESC")
fun getBookmarksByTag(tag: String): Flow<List<BookmarkEntity>>
@Query("SELECT * FROM bookmarks ORDER BY createdAt DESC LIMIT :limit OFFSET :offset")
@@ -47,6 +47,6 @@ interface BookmarkDao {
@Query("SELECT COUNT(*) FROM bookmarks")
fun getBookmarkCount(): Flow<Int>
@Query("SELECT COUNT(*) FROM bookmarks WHERE tags LIKE '%' || :tag || '%'")
@Query("SELECT COUNT(*) FROM bookmarks WHERE tags LIKE :tagPattern")
fun getBookmarkCountByTag(tag: String): Flow<Int>
}

View File

@@ -0,0 +1,26 @@
package com.rssuper.database.daos
import androidx.room.*
import com.rssuper.database.entities.NotificationPreferencesEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface NotificationPreferencesDao {
@Query("SELECT * FROM notification_preferences WHERE id = :id LIMIT 1")
fun get(id: String): Flow<NotificationPreferencesEntity?>
@Query("SELECT * FROM notification_preferences WHERE id = :id LIMIT 1")
fun getSync(id: String): NotificationPreferencesEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: NotificationPreferencesEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(vararg entities: NotificationPreferencesEntity)
@Update
suspend fun update(entity: NotificationPreferencesEntity)
@Delete
suspend fun delete(entity: NotificationPreferencesEntity)
}

View File

@@ -4,11 +4,18 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.rssuper.database.entities.FeedItemEntity
import java.util.Date
@Entity(
tableName = "bookmarks",
indices = [Index(value = ["feedItemId"], unique = true)]
indices = [Index(value = ["feedItemId"], unique = true)],
foreignKeys = [ForeignKey(
entity = FeedItemEntity::class,
parentColumns = ["id"],
childColumns = ["feedItemId"],
onDelete = ForeignKey.CASCADE
)]
)
data class BookmarkEntity(
@PrimaryKey

View File

@@ -0,0 +1,37 @@
package com.rssuper.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.rssuper.models.NotificationPreferences
@Entity(tableName = "notification_preferences")
data class NotificationPreferencesEntity(
@PrimaryKey
val id: String = "default",
val newArticles: Boolean = true,
val episodeReleases: Boolean = true,
val customAlerts: Boolean = false,
val badgeCount: Boolean = true,
val sound: Boolean = true,
val vibration: Boolean = true
) {
fun toModel(): NotificationPreferences = NotificationPreferences(
id = id,
newArticles = newArticles,
episodeReleases = episodeReleases,
customAlerts = customAlerts,
badgeCount = badgeCount,
sound = sound,
vibration = vibration
)
}
fun NotificationPreferences.toEntity(): NotificationPreferencesEntity = NotificationPreferencesEntity(
id = id,
newArticles = newArticles,
episodeReleases = episodeReleases,
customAlerts = customAlerts,
badgeCount = badgeCount,
sound = sound,
vibration = vibration
)

View File

@@ -15,5 +15,7 @@ data class SearchHistoryEntity(
val query: String,
val timestamp: Date
val filtersJson: String? = null,
val timestamp: Long
)

View File

@@ -9,6 +9,14 @@ import kotlinx.coroutines.flow.map
class BookmarkRepository(
private val bookmarkDao: BookmarkDao
) {
private inline fun <T> safeExecute(operation: () -> T): T {
return try {
operation()
} catch (e: Exception) {
throw RuntimeException("Operation failed", e)
}
}
fun getAllBookmarks(): Flow<BookmarkState> {
return bookmarkDao.getAllBookmarks().map { bookmarks ->
BookmarkState.Success(bookmarks)
@@ -18,74 +26,54 @@ class BookmarkRepository(
}
fun getBookmarksByTag(tag: String): Flow<BookmarkState> {
return bookmarkDao.getBookmarksByTag(tag).map { bookmarks ->
val tagPattern = "%${tag.trim()}%"
return bookmarkDao.getBookmarksByTag(tagPattern).map { bookmarks ->
BookmarkState.Success(bookmarks)
}.catch { e ->
emit(BookmarkState.Error("Failed to load bookmarks by tag", e))
}
}
suspend fun getBookmarkById(id: String): BookmarkEntity? {
return try {
bookmarkDao.getBookmarkById(id)
} catch (e: Exception) {
throw RuntimeException("Failed to get bookmark", e)
suspend fun getBookmarkById(id: String): BookmarkEntity? = safeExecute {
bookmarkDao.getBookmarkById(id)
}
suspend fun getBookmarkByFeedItemId(feedItemId: String): BookmarkEntity? = safeExecute {
bookmarkDao.getBookmarkByFeedItemId(feedItemId)
}
suspend fun insertBookmark(bookmark: BookmarkEntity): Long = safeExecute {
bookmarkDao.insertBookmark(bookmark)
}
suspend fun insertBookmarks(bookmarks: List<BookmarkEntity>): List<Long> = safeExecute {
bookmarkDao.insertBookmarks(bookmarks)
}
suspend fun updateBookmark(bookmark: BookmarkEntity): Int = safeExecute {
bookmarkDao.updateBookmark(bookmark)
}
suspend fun deleteBookmark(bookmark: BookmarkEntity): Int = safeExecute {
bookmarkDao.deleteBookmark(bookmark)
}
suspend fun deleteBookmarkById(id: String): Int = safeExecute {
bookmarkDao.deleteBookmarkById(id)
}
suspend fun deleteBookmarkByFeedItemId(feedItemId: String): Int = safeExecute {
bookmarkDao.deleteBookmarkByFeedItemId(feedItemId)
}
suspend fun getBookmarksPaginated(limit: Int, offset: Int): List<BookmarkEntity> {
return safeExecute {
bookmarkDao.getBookmarksPaginated(limit, offset)
}
}
suspend fun getBookmarkByFeedItemId(feedItemId: String): BookmarkEntity? {
return try {
bookmarkDao.getBookmarkByFeedItemId(feedItemId)
} catch (e: Exception) {
throw RuntimeException("Failed to get bookmark by feed item ID", e)
}
}
suspend fun insertBookmark(bookmark: BookmarkEntity): Long {
return try {
bookmarkDao.insertBookmark(bookmark)
} catch (e: Exception) {
throw RuntimeException("Failed to insert bookmark", e)
}
}
suspend fun insertBookmarks(bookmarks: List<BookmarkEntity>): List<Long> {
return try {
bookmarkDao.insertBookmarks(bookmarks)
} catch (e: Exception) {
throw RuntimeException("Failed to insert bookmarks", e)
}
}
suspend fun updateBookmark(bookmark: BookmarkEntity): Int {
return try {
bookmarkDao.updateBookmark(bookmark)
} catch (e: Exception) {
throw RuntimeException("Failed to update bookmark", e)
}
}
suspend fun deleteBookmark(bookmark: BookmarkEntity): Int {
return try {
bookmarkDao.deleteBookmark(bookmark)
} catch (e: Exception) {
throw RuntimeException("Failed to delete bookmark", e)
}
}
suspend fun deleteBookmarkById(id: String): Int {
return try {
bookmarkDao.deleteBookmarkById(id)
} catch (e: Exception) {
throw RuntimeException("Failed to delete bookmark by ID", e)
}
}
suspend fun deleteBookmarkByFeedItemId(feedItemId: String): Int {
return try {
bookmarkDao.deleteBookmarkByFeedItemId(feedItemId)
} catch (e: Exception) {
throw RuntimeException("Failed to delete bookmark by feed item ID", e)
}
fun getBookmarkCountByTag(tag: String): Flow<Int> {
val tagPattern = "%${tag.trim()}%"
return bookmarkDao.getBookmarkCountByTag(tagPattern)
}
}

View File

@@ -14,18 +14,39 @@ class SearchService(
private val searchHistoryDao: SearchHistoryDao,
private val resultProvider: SearchResultProvider
) {
private val cache = mutableMapOf<String, List<SearchResult>>()
private data class CacheEntry(val results: List<SearchResult>, val timestamp: Long)
private val cache = mutableMapOf<String, CacheEntry>()
private val maxCacheSize = 100
private val cacheExpirationMs = 5 * 60 * 1000L // 5 minutes
private fun isCacheEntryExpired(entry: CacheEntry): Boolean {
return System.currentTimeMillis() - entry.timestamp > cacheExpirationMs
}
private fun cleanExpiredCacheEntries() {
cache.keys.removeAll { key ->
cache[key]?.let { isCacheEntryExpired(it) } ?: false
}
}
fun search(query: String): Flow<List<SearchResult>> {
val cacheKey = query.hashCode().toString()
// Return cached results if available
cache[cacheKey]?.let { return flow { emit(it) } }
// Clean expired entries periodically
if (cache.size > maxCacheSize / 2) {
cleanExpiredCacheEntries()
}
// Return cached results if available and not expired
cache[cacheKey]?.let { entry ->
if (!isCacheEntryExpired(entry)) {
return flow { emit(entry.results) }
}
}
return flow {
val results = resultProvider.search(query)
cache[cacheKey] = results
cache[cacheKey] = CacheEntry(results, System.currentTimeMillis())
if (cache.size > maxCacheSize) {
cache.remove(cache.keys.first())
}

View File

@@ -0,0 +1,121 @@
package com.rssuper.services
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.rssuper.database.RssDatabase
import com.rssuper.database.entities.NotificationPreferencesEntity
import com.rssuper.database.entities.toEntity
import com.rssuper.models.NotificationPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class NotificationManager(private val context: Context) {
private val notificationService: NotificationService = NotificationService(context)
private val database: RssDatabase = RssDatabase.getDatabase(context)
private var unreadCount: Int = 0
suspend fun initialize() {
val preferences = notificationService.getPreferences()
if (!preferences.badgeCount) {
clearBadge()
}
}
suspend fun showNotification(
title: String,
body: String,
type: NotificationType = NotificationType.NEW_ARTICLE
) {
val preferences = notificationService.getPreferences()
if (!shouldShowNotification(type, preferences)) {
return
}
val shouldAddBadge = preferences.badgeCount && type != NotificationType.LOW_PRIORITY
if (shouldAddBadge) {
incrementBadgeCount()
}
val priority = when (type) {
NotificationType.NEW_ARTICLE -> NotificationCompat.PRIORITY_DEFAULT
NotificationType.PODCAST_EPISODE -> NotificationCompat.PRIORITY_HIGH
NotificationType.LOW_PRIORITY -> NotificationCompat.PRIORITY_LOW
NotificationType.CRITICAL -> NotificationCompat.PRIORITY_MAX
}
notificationService.showNotification(title, body, priority)
}
suspend fun showLocalNotification(
title: String,
body: String,
delayMillis: Long = 0
) {
notificationService.showLocalNotification(title, body, delayMillis)
}
suspend fun showPushNotification(
title: String,
body: String,
data: Map<String, String> = emptyMap()
) {
notificationService.showPushNotification(title, body, data)
}
suspend fun incrementBadgeCount() {
unreadCount++
updateBadge()
}
suspend fun clearBadge() {
unreadCount = 0
updateBadge()
}
suspend fun getBadgeCount(): Int {
return unreadCount
}
private suspend fun updateBadge() {
notificationService.updateBadgeCount(unreadCount)
}
private suspend fun shouldShowNotification(type: NotificationType, preferences: NotificationPreferences): Boolean {
return when (type) {
NotificationType.NEW_ARTICLE -> preferences.newArticles
NotificationType.PODCAST_EPISODE -> preferences.episodeReleases
NotificationType.LOW_PRIORITY, NotificationType.CRITICAL -> true
}
}
suspend fun setPreferences(preferences: NotificationPreferences) {
notificationService.savePreferences(preferences)
}
suspend fun getPreferences(): NotificationPreferences {
return notificationService.getPreferences()
}
fun hasPermission(): Boolean {
return notificationService.hasNotificationPermission()
}
fun requestPermission() {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
// Request permission from UI
// This should be called from an Activity
}
}
}
enum class NotificationType {
NEW_ARTICLE,
PODCAST_EPISODE,
LOW_PRIORITY,
CRITICAL
}

View File

@@ -0,0 +1,73 @@
package com.rssuper.services
import android.content.Context
import com.rssuper.database.RssDatabase
import com.rssuper.database.entities.NotificationPreferencesEntity
import com.rssuper.database.entities.toEntity
import com.rssuper.models.NotificationPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class NotificationPreferencesStore(private val context: Context) {
private val database: RssDatabase = RssDatabase.getDatabase(context)
suspend fun getPreferences(): NotificationPreferences {
return withContext(Dispatchers.IO) {
val entity = database.notificationPreferencesDao().getSync("default")
entity?.toModel() ?: NotificationPreferences()
}
}
suspend fun savePreferences(preferences: NotificationPreferences) {
withContext(Dispatchers.IO) {
database.notificationPreferencesDao().insert(preferences.toEntity())
}
}
suspend fun updatePreference(
newArticles: Boolean? = null,
episodeReleases: Boolean? = null,
customAlerts: Boolean? = null,
badgeCount: Boolean? = null,
sound: Boolean? = null,
vibration: Boolean? = null
) {
withContext(Dispatchers.IO) {
val current = database.notificationPreferencesDao().getSync("default")
val preferences = current?.toModel() ?: NotificationPreferences()
val updated = preferences.copy(
newArticles = newArticles ?: preferences.newArticles,
episodeReleases = episodeReleases ?: preferences.episodeReleases,
customAlerts = customAlerts ?: preferences.customAlerts,
badgeCount = badgeCount ?: preferences.badgeCount,
sound = sound ?: preferences.sound,
vibration = vibration ?: preferences.vibration
)
database.notificationPreferencesDao().insert(updated.toEntity())
}
}
suspend fun isNotificationEnabled(type: NotificationType): Boolean {
val preferences = getPreferences()
return when (type) {
NotificationType.NEW_ARTICLE -> preferences.newArticles
NotificationType.PODCAST_EPISODE -> preferences.episodeReleases
NotificationType.LOW_PRIORITY, NotificationType.CRITICAL -> true
}
}
suspend fun isSoundEnabled(): Boolean {
return getPreferences().sound
}
suspend fun isVibrationEnabled(): Boolean {
return getPreferences().vibration
}
suspend fun isBadgeEnabled(): Boolean {
return getPreferences().badgeCount
}
}

View File

@@ -0,0 +1,177 @@
package com.rssuper.services
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.rssuper.R
import com.rssuper.database.RssDatabase
import com.rssuper.database.entities.NotificationPreferencesEntity
import com.rssuper.database.entities.toEntity
import com.rssuper.models.NotificationPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.UUID
const val NOTIFICATION_CHANNEL_ID = "rssuper_notifications"
const val NOTIFICATION_CHANNEL_NAME = "RSSuper Notifications"
class NotificationService(private val context: Context) {
private val database: RssDatabase = RssDatabase.getDatabase(context)
private var notificationManager: NotificationManager? = null
init {
notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannels()
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
NOTIFICATION_CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Notifications for new articles and episode releases"
enableVibration(true)
enableLights(true)
}
notificationManager?.createNotificationChannel(channel)
}
}
suspend fun getPreferences(): NotificationPreferences {
return withContext(Dispatchers.IO) {
val entity = database.notificationPreferencesDao().getSync("default")
entity?.toModel() ?: NotificationPreferences()
}
}
suspend fun savePreferences(preferences: NotificationPreferences) {
withContext(Dispatchers.IO) {
database.notificationPreferencesDao().insert(preferences.toEntity())
}
}
fun showNotification(
title: String,
body: String,
priority: NotificationCompat.Priority = NotificationCompat.PRIORITY_DEFAULT
): Boolean {
if (!hasNotificationPermission()) {
return false
}
val notification = createNotification(title, body, priority)
val notificationId = generateNotificationId()
NotificationManagerCompat.from(context).notify(notificationId, notification)
return true
}
fun showLocalNotification(
title: String,
body: String,
delayMillis: Long = 0
): Boolean {
if (!hasNotificationPermission()) {
return false
}
val notification = createNotification(title, body)
val notificationId = generateNotificationId()
if (delayMillis > 0) {
// For delayed notifications, we would use AlarmManager or WorkManager
// This is a simplified version that shows immediately
NotificationManagerCompat.from(context).notify(notificationId, notification)
} else {
NotificationManagerCompat.from(context).notify(notificationId, notification)
}
return true
}
fun showPushNotification(
title: String,
body: String,
data: Map<String, String> = emptyMap()
): Boolean {
if (!hasNotificationPermission()) {
return false
}
val notification = createNotification(title, body)
val notificationId = generateNotificationId()
NotificationManagerCompat.from(context).notify(notificationId, notification)
return true
}
fun showNotificationWithAction(
title: String,
body: String,
actionLabel: String,
actionIntent: PendingIntent
): Boolean {
if (!hasNotificationPermission()) {
return false
}
val notification = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.addAction(android.R.drawable.ic_menu_share, actionLabel, actionIntent)
.setAutoCancel(true)
.build()
val notificationId = generateNotificationId()
NotificationManagerCompat.from(context).notify(notificationId, notification)
return true
}
fun updateBadgeCount(count: Int) {
// On Android, badge count is handled by the system based on notifications
// For launcher icons that support badges, we can use NotificationManagerCompat
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Android 8.0+ handles badge counts automatically
// No explicit action needed
}
}
fun clearAllNotifications() {
notificationManager?.cancelAll()
}
fun hasNotificationPermission(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
return true
}
private fun createNotification(
title: String,
body: String,
priority: Int = NotificationCompat.PRIORITY_DEFAULT
): Notification {
return NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setPriority(priority)
.setAutoCancel(true)
.build()
}
private fun generateNotificationId(): Int {
return UUID.randomUUID().hashCode()
}
}

View File

@@ -0,0 +1,193 @@
package com.rssuper.settings
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.createDataStore
import com.rssuper.models.FeedSize
import com.rssuper.models.LineHeight
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class SettingsStore(private val context: Context) {
private val dataStore: DataStore<Preferences> = context.createDataStore(name = "settings")
// Keys
private val FONT_SIZE_KEY = stringPreferencesKey("font_size")
private val LINE_HEIGHT_KEY = stringPreferencesKey("line_height")
private val SHOW_TABLE_OF_CONTENTS_KEY = booleanPreferencesKey("show_table_of_contents")
private val SHOW_READING_TIME_KEY = booleanPreferencesKey("show_reading_time")
private val SHOW_AUTHOR_KEY = booleanPreferencesKey("show_author")
private val SHOW_DATE_KEY = booleanPreferencesKey("show_date")
private val NEW_ARTICLES_KEY = booleanPreferencesKey("new_articles")
private val EPISODE_RELEASES_KEY = booleanPreferencesKey("episode_releases")
private val CUSTOM_ALERTS_KEY = booleanPreferencesKey("custom_alerts")
private val BADGE_COUNT_KEY = booleanPreferencesKey("badge_count")
private val SOUND_KEY = booleanPreferencesKey("sound")
private val VIBRATION_KEY = booleanPreferencesKey("vibration")
// Reading Preferences
val fontSize: Flow<FontSize> = dataStore.data.map { preferences ->
val value = preferences[FONT_SIZE_KEY] ?: FontSize.MEDIUM.value
return@map FontSize.fromValue(value)
}
val lineHeight: Flow<LineHeight> = dataStore.data.map { preferences ->
val value = preferences[LINE_HEIGHT_KEY] ?: LineHeight.NORMAL.value
return@map LineHeight.fromValue(value)
}
val showTableOfContents: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[SHOW_TABLE_OF_CONTENTS_KEY] ?: false
}
val showReadingTime: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[SHOW_READING_TIME_KEY] ?: true
}
val showAuthor: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[SHOW_AUTHOR_KEY] ?: true
}
val showDate: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[SHOW_DATE_KEY] ?: true
}
// Notification Preferences
val newArticles: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[NEW_ARTICLES_KEY] ?: true
}
val episodeReleases: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[EPISODE_RELEASES_KEY] ?: true
}
val customAlerts: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[CUSTOM_ALERTS_KEY] ?: false
}
val badgeCount: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[BADGE_COUNT_KEY] ?: true
}
val sound: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[SOUND_KEY] ?: true
}
val vibration: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[VIBRATION_KEY] ?: true
}
// Reading Preferences
suspend fun setFontSize(fontSize: FontSize) {
dataStore.edit { preferences ->
preferences[FONT_SIZE_KEY] = fontSize.value
}
}
suspend fun setLineHeight(lineHeight: LineHeight) {
dataStore.edit { preferences ->
preferences[LINE_HEIGHT_KEY] = lineHeight.value
}
}
suspend fun setShowTableOfContents(show: Boolean) {
dataStore.edit { preferences ->
preferences[SHOW_TABLE_OF_CONTENTS_KEY] = show
}
}
suspend fun setShowReadingTime(show: Boolean) {
dataStore.edit { preferences ->
preferences[SHOW_READING_TIME_KEY] = show
}
}
suspend fun setShowAuthor(show: Boolean) {
dataStore.edit { preferences ->
preferences[SHOW_AUTHOR_KEY] = show
}
}
suspend fun setShowDate(show: Boolean) {
dataStore.edit { preferences ->
preferences[SHOW_DATE_KEY] = show
}
}
// Notification Preferences
suspend fun setNewArticles(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[NEW_ARTICLES_KEY] = enabled
}
}
suspend fun setEpisodeReleases(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[EPISODE_RELEASES_KEY] = enabled
}
}
suspend fun setCustomAlerts(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[CUSTOM_ALERTS_KEY] = enabled
}
}
suspend fun setBadgeCount(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[BADGE_COUNT_KEY] = enabled
}
}
suspend fun setSound(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[SOUND_KEY] = enabled
}
}
suspend fun setVibration(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[VIBRATION_KEY] = enabled
}
}
}
// Extension functions for enum conversion
fun FontSize.Companion.fromValue(value: String): FontSize {
return when (value) {
"small" -> FontSize.SMALL
"medium" -> FontSize.MEDIUM
"large" -> FontSize.LARGE
"xlarge" -> FontSize.XLARGE
else -> FontSize.MEDIUM
}
}
fun LineHeight.Companion.fromValue(value: String): LineHeight {
return when (value) {
"normal" -> LineHeight.NORMAL
"relaxed" -> LineHeight.RELAXED
"loose" -> LineHeight.LOOSE
else -> LineHeight.NORMAL
}
}
// Extension properties for enum value
val FontSize.value: String
get() = when (this) {
FontSize.SMALL -> "small"
FontSize.MEDIUM -> "medium"
FontSize.LARGE -> "large"
FontSize.XLARGE -> "xlarge"
}
val LineHeight.value: String
get() = when (this) {
LineHeight.NORMAL -> "normal"
LineHeight.RELAXED -> "relaxed"
LineHeight.LOOSE -> "loose"
}