mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add multi-account support with account switcher and data isolation
This commit is contained in:
parent
74f42fd2f1
commit
5001ba18cb
17 changed files with 1661 additions and 180 deletions
|
|
@ -5,7 +5,7 @@ import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.swoosh.microblog.data.CredentialsManager
|
import com.swoosh.microblog.data.AccountManager
|
||||||
import com.swoosh.microblog.ui.navigation.Routes
|
import com.swoosh.microblog.ui.navigation.Routes
|
||||||
import com.swoosh.microblog.ui.navigation.SwooshNavGraph
|
import com.swoosh.microblog.ui.navigation.SwooshNavGraph
|
||||||
import com.swoosh.microblog.ui.theme.SwooshTheme
|
import com.swoosh.microblog.ui.theme.SwooshTheme
|
||||||
|
|
@ -15,8 +15,8 @@ class MainActivity : ComponentActivity() {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
val credentials = CredentialsManager(this)
|
val accountManager = AccountManager(this)
|
||||||
val startDestination = if (credentials.isConfigured) Routes.FEED else Routes.SETUP
|
val startDestination = if (accountManager.isConfigured) Routes.FEED else Routes.SETUP
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SwooshTheme {
|
SwooshTheme {
|
||||||
|
|
|
||||||
214
app/src/main/java/com/swoosh/microblog/data/AccountManager.kt
Normal file
214
app/src/main/java/com/swoosh/microblog/data/AccountManager.kt
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
package com.swoosh.microblog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import com.swoosh.microblog.data.model.GhostAccount
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class AccountManager private constructor(
|
||||||
|
private val prefs: SharedPreferences,
|
||||||
|
private val shouldMigrate: Boolean = true
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KEY_ACCOUNTS = "ghost_accounts"
|
||||||
|
const val KEY_ACTIVE_ACCOUNT_ID = "active_account_id"
|
||||||
|
const val MAX_ACCOUNTS = 5
|
||||||
|
|
||||||
|
// Legacy keys for migration
|
||||||
|
private const val LEGACY_KEY_GHOST_URL = "ghost_url"
|
||||||
|
private const val LEGACY_KEY_ADMIN_API_KEY = "admin_api_key"
|
||||||
|
private const val KEY_MIGRATION_DONE = "multi_account_migration_done"
|
||||||
|
|
||||||
|
private fun createEncryptedPrefs(context: Context): SharedPreferences {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"swoosh_secure_prefs",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(context: Context) : this(createEncryptedPrefs(context), shouldMigrate = true)
|
||||||
|
|
||||||
|
/** Constructor for testing with plain SharedPreferences */
|
||||||
|
constructor(prefs: SharedPreferences) : this(prefs, shouldMigrate = true)
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (shouldMigrate) {
|
||||||
|
migrateIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun migrateIfNeeded() {
|
||||||
|
if (prefs.getBoolean(KEY_MIGRATION_DONE, false)) return
|
||||||
|
|
||||||
|
val legacyUrl = prefs.getString(LEGACY_KEY_GHOST_URL, null)
|
||||||
|
val legacyKey = prefs.getString(LEGACY_KEY_ADMIN_API_KEY, null)
|
||||||
|
|
||||||
|
if (!legacyUrl.isNullOrBlank() && !legacyKey.isNullOrBlank()) {
|
||||||
|
val account = GhostAccount(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
name = extractBlogName(legacyUrl),
|
||||||
|
blogUrl = legacyUrl,
|
||||||
|
apiKey = legacyKey,
|
||||||
|
isActive = true,
|
||||||
|
colorIndex = 0
|
||||||
|
)
|
||||||
|
val accounts = listOf(account)
|
||||||
|
saveAccountsList(accounts)
|
||||||
|
prefs.edit().putString(KEY_ACTIVE_ACCOUNT_ID, account.id).apply()
|
||||||
|
|
||||||
|
// Remove legacy keys
|
||||||
|
prefs.edit()
|
||||||
|
.remove(LEGACY_KEY_GHOST_URL)
|
||||||
|
.remove(LEGACY_KEY_ADMIN_API_KEY)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs.edit().putBoolean(KEY_MIGRATION_DONE, true).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAccounts(): List<GhostAccount> {
|
||||||
|
val json = prefs.getString(KEY_ACCOUNTS, null) ?: return emptyList()
|
||||||
|
val type = object : TypeToken<List<GhostAccount>>() {}.type
|
||||||
|
return try {
|
||||||
|
val accounts: List<GhostAccount> = gson.fromJson(json, type)
|
||||||
|
val activeId = prefs.getString(KEY_ACTIVE_ACCOUNT_ID, null)
|
||||||
|
accounts.map { it.copy(isActive = it.id == activeId) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getActiveAccount(): GhostAccount? {
|
||||||
|
val activeId = prefs.getString(KEY_ACTIVE_ACCOUNT_ID, null) ?: return null
|
||||||
|
return getAccounts().find { it.id == activeId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addAccount(name: String, blogUrl: String, apiKey: String): Result<GhostAccount> {
|
||||||
|
val accounts = getAccounts()
|
||||||
|
if (accounts.size >= MAX_ACCOUNTS) {
|
||||||
|
return Result.failure(IllegalStateException("Maximum of $MAX_ACCOUNTS accounts reached"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val colorIndex = nextAvailableColorIndex(accounts)
|
||||||
|
val account = GhostAccount(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
name = name.ifBlank { extractBlogName(blogUrl) },
|
||||||
|
blogUrl = blogUrl,
|
||||||
|
apiKey = apiKey,
|
||||||
|
isActive = true,
|
||||||
|
colorIndex = colorIndex
|
||||||
|
)
|
||||||
|
|
||||||
|
val updatedAccounts = accounts + account
|
||||||
|
saveAccountsList(updatedAccounts)
|
||||||
|
setActiveAccount(account.id)
|
||||||
|
|
||||||
|
return Result.success(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAccount(id: String): Boolean {
|
||||||
|
val accounts = getAccounts().toMutableList()
|
||||||
|
val removed = accounts.removeAll { it.id == id }
|
||||||
|
if (!removed) return false
|
||||||
|
|
||||||
|
saveAccountsList(accounts)
|
||||||
|
|
||||||
|
// If the removed account was active, switch to first available
|
||||||
|
val activeId = prefs.getString(KEY_ACTIVE_ACCOUNT_ID, null)
|
||||||
|
if (activeId == id) {
|
||||||
|
val nextAccount = accounts.firstOrNull()
|
||||||
|
if (nextAccount != null) {
|
||||||
|
setActiveAccount(nextAccount.id)
|
||||||
|
} else {
|
||||||
|
prefs.edit().remove(KEY_ACTIVE_ACCOUNT_ID).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setActiveAccount(id: String): Boolean {
|
||||||
|
val accounts = getAccounts()
|
||||||
|
if (accounts.none { it.id == id }) return false
|
||||||
|
prefs.edit().putString(KEY_ACTIVE_ACCOUNT_ID, id).apply()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateAccount(id: String, name: String? = null, blogUrl: String? = null, apiKey: String? = null): Boolean {
|
||||||
|
val accounts = getAccounts().toMutableList()
|
||||||
|
val index = accounts.indexOfFirst { it.id == id }
|
||||||
|
if (index == -1) return false
|
||||||
|
|
||||||
|
val current = accounts[index]
|
||||||
|
accounts[index] = current.copy(
|
||||||
|
name = name ?: current.name,
|
||||||
|
blogUrl = blogUrl ?: current.blogUrl,
|
||||||
|
apiKey = apiKey ?: current.apiKey
|
||||||
|
)
|
||||||
|
|
||||||
|
saveAccountsList(accounts)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val isConfigured: Boolean
|
||||||
|
get() = getActiveAccount() != null
|
||||||
|
|
||||||
|
val hasAnyAccount: Boolean
|
||||||
|
get() = getAccounts().isNotEmpty()
|
||||||
|
|
||||||
|
fun clearAll() {
|
||||||
|
prefs.edit()
|
||||||
|
.remove(KEY_ACCOUNTS)
|
||||||
|
.remove(KEY_ACTIVE_ACCOUNT_ID)
|
||||||
|
.remove(KEY_MIGRATION_DONE)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAccountsList(accounts: List<GhostAccount>) {
|
||||||
|
// Strip isActive before saving since it's derived from KEY_ACTIVE_ACCOUNT_ID
|
||||||
|
val toSave = accounts.map { it.copy(isActive = false) }
|
||||||
|
val json = gson.toJson(toSave)
|
||||||
|
prefs.edit().putString(KEY_ACCOUNTS, json).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextAvailableColorIndex(accounts: List<GhostAccount>): Int {
|
||||||
|
val usedIndices = accounts.map { it.colorIndex }.toSet()
|
||||||
|
for (i in 0 until GhostAccount.ACCOUNT_COLORS.size) {
|
||||||
|
if (i !in usedIndices) return i
|
||||||
|
}
|
||||||
|
return accounts.size % GhostAccount.ACCOUNT_COLORS.size
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun extractBlogName(url: String): String {
|
||||||
|
return try {
|
||||||
|
val cleaned = url.trim()
|
||||||
|
.removePrefix("https://")
|
||||||
|
.removePrefix("http://")
|
||||||
|
.removeSuffix("/")
|
||||||
|
val host = cleaned.split("/").first()
|
||||||
|
// Remove common suffixes
|
||||||
|
host.removeSuffix(".ghost.io")
|
||||||
|
.removeSuffix(".com")
|
||||||
|
.removeSuffix(".org")
|
||||||
|
.removeSuffix(".net")
|
||||||
|
.replaceFirstChar { it.uppercase() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"My Blog"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,41 +1,34 @@
|
||||||
package com.swoosh.microblog.data
|
package com.swoosh.microblog.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
|
||||||
import androidx.security.crypto.MasterKey
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy compatibility wrapper. Now delegates to AccountManager.
|
||||||
|
* Kept for backward compatibility during migration.
|
||||||
|
*/
|
||||||
class CredentialsManager(context: Context) {
|
class CredentialsManager(context: Context) {
|
||||||
|
|
||||||
private val masterKey = MasterKey.Builder(context)
|
private val accountManager = AccountManager(context)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
|
|
||||||
context,
|
|
||||||
"swoosh_secure_prefs",
|
|
||||||
masterKey,
|
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
|
||||||
)
|
|
||||||
|
|
||||||
var ghostUrl: String?
|
var ghostUrl: String?
|
||||||
get() = prefs.getString(KEY_GHOST_URL, null)
|
get() = accountManager.getActiveAccount()?.blogUrl
|
||||||
set(value) = prefs.edit().putString(KEY_GHOST_URL, value).apply()
|
set(_) {
|
||||||
|
// No-op: use AccountManager directly for mutations
|
||||||
|
}
|
||||||
|
|
||||||
var adminApiKey: String?
|
var adminApiKey: String?
|
||||||
get() = prefs.getString(KEY_ADMIN_API_KEY, null)
|
get() = accountManager.getActiveAccount()?.apiKey
|
||||||
set(value) = prefs.edit().putString(KEY_ADMIN_API_KEY, value).apply()
|
set(_) {
|
||||||
|
// No-op: use AccountManager directly for mutations
|
||||||
|
}
|
||||||
|
|
||||||
val isConfigured: Boolean
|
val isConfigured: Boolean
|
||||||
get() = !ghostUrl.isNullOrBlank() && !adminApiKey.isNullOrBlank()
|
get() = accountManager.isConfigured
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
prefs.edit().clear().apply()
|
val activeAccount = accountManager.getActiveAccount()
|
||||||
|
if (activeAccount != null) {
|
||||||
|
accountManager.removeAccount(activeAccount.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val KEY_GHOST_URL = "ghost_url"
|
|
||||||
private const val KEY_ADMIN_API_KEY = "admin_api_key"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@ import androidx.room.Database
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.swoosh.microblog.data.model.LocalPost
|
import com.swoosh.microblog.data.model.LocalPost
|
||||||
|
|
||||||
@Database(entities = [LocalPost::class], version = 1, exportSchema = false)
|
@Database(entities = [LocalPost::class], version = 2, exportSchema = false)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
|
@ -17,13 +19,21 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
@Volatile
|
@Volatile
|
||||||
private var INSTANCE: AppDatabase? = null
|
private var INSTANCE: AppDatabase? = null
|
||||||
|
|
||||||
|
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE local_posts ADD COLUMN accountId TEXT NOT NULL DEFAULT ''")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getInstance(context: Context): AppDatabase {
|
fun getInstance(context: Context): AppDatabase {
|
||||||
return INSTANCE ?: synchronized(this) {
|
return INSTANCE ?: synchronized(this) {
|
||||||
val instance = Room.databaseBuilder(
|
val instance = Room.databaseBuilder(
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"swoosh_database"
|
"swoosh_database"
|
||||||
).build()
|
)
|
||||||
|
.addMigrations(MIGRATION_1_2)
|
||||||
|
.build()
|
||||||
INSTANCE = instance
|
INSTANCE = instance
|
||||||
instance
|
instance
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,20 @@ interface LocalPostDao {
|
||||||
@Query("SELECT * FROM local_posts ORDER BY updatedAt DESC")
|
@Query("SELECT * FROM local_posts ORDER BY updatedAt DESC")
|
||||||
fun getAllPosts(): Flow<List<LocalPost>>
|
fun getAllPosts(): Flow<List<LocalPost>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM local_posts WHERE accountId = :accountId ORDER BY updatedAt DESC")
|
||||||
|
fun getPostsByAccount(accountId: String): Flow<List<LocalPost>>
|
||||||
|
|
||||||
@Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC")
|
@Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC")
|
||||||
suspend fun getQueuedPosts(
|
suspend fun getQueuedPosts(
|
||||||
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED)
|
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED)
|
||||||
): List<LocalPost>
|
): List<LocalPost>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM local_posts WHERE accountId = :accountId AND queueStatus IN (:statuses) ORDER BY createdAt ASC")
|
||||||
|
suspend fun getQueuedPostsByAccount(
|
||||||
|
accountId: String,
|
||||||
|
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED)
|
||||||
|
): List<LocalPost>
|
||||||
|
|
||||||
@Query("SELECT * FROM local_posts WHERE localId = :localId")
|
@Query("SELECT * FROM local_posts WHERE localId = :localId")
|
||||||
suspend fun getPostById(localId: Long): LocalPost?
|
suspend fun getPostById(localId: Long): LocalPost?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.swoosh.microblog.data.model
|
||||||
|
|
||||||
|
data class GhostAccount(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val blogUrl: String,
|
||||||
|
val apiKey: String,
|
||||||
|
val isActive: Boolean = false,
|
||||||
|
val colorIndex: Int = 0
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val ACCOUNT_COLORS = listOf(
|
||||||
|
0xFF6750A4, // Purple
|
||||||
|
0xFF00796B, // Teal
|
||||||
|
0xFFD32F2F, // Red
|
||||||
|
0xFF1976D2, // Blue
|
||||||
|
0xFFF57C00, // Orange
|
||||||
|
)
|
||||||
|
|
||||||
|
fun colorForIndex(index: Int): Long {
|
||||||
|
return ACCOUNT_COLORS[index % ACCOUNT_COLORS.size]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,7 @@ data class LocalPost(
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
val localId: Long = 0,
|
val localId: Long = 0,
|
||||||
val ghostId: String? = null,
|
val ghostId: String? = null,
|
||||||
|
val accountId: String = "",
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val content: String = "",
|
val content: String = "",
|
||||||
val htmlContent: String? = null,
|
val htmlContent: String? = null,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package com.swoosh.microblog.data.repository
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.swoosh.microblog.data.CredentialsManager
|
import com.swoosh.microblog.data.AccountManager
|
||||||
import com.swoosh.microblog.data.api.ApiClient
|
import com.swoosh.microblog.data.api.ApiClient
|
||||||
import com.swoosh.microblog.data.api.GhostApiService
|
import com.swoosh.microblog.data.api.GhostApiService
|
||||||
import com.swoosh.microblog.data.db.AppDatabase
|
import com.swoosh.microblog.data.db.AppDatabase
|
||||||
|
|
@ -20,12 +20,17 @@ import java.io.FileOutputStream
|
||||||
|
|
||||||
class PostRepository(private val context: Context) {
|
class PostRepository(private val context: Context) {
|
||||||
|
|
||||||
private val credentials = CredentialsManager(context)
|
private val accountManager = AccountManager(context)
|
||||||
private val dao: LocalPostDao = AppDatabase.getInstance(context).localPostDao()
|
private val dao: LocalPostDao = AppDatabase.getInstance(context).localPostDao()
|
||||||
|
|
||||||
|
fun getActiveAccountId(): String {
|
||||||
|
return accountManager.getActiveAccount()?.id ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
private fun getApi(): GhostApiService {
|
private fun getApi(): GhostApiService {
|
||||||
val url = credentials.ghostUrl ?: throw IllegalStateException("Ghost URL not configured")
|
val account = accountManager.getActiveAccount()
|
||||||
return ApiClient.getService(url) { credentials.adminApiKey }
|
?: throw IllegalStateException("No active account configured")
|
||||||
|
return ApiClient.getService(account.blogUrl) { account.apiKey }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Remote operations ---
|
// --- Remote operations ---
|
||||||
|
|
@ -122,11 +127,25 @@ class PostRepository(private val context: Context) {
|
||||||
|
|
||||||
// --- Local operations ---
|
// --- Local operations ---
|
||||||
|
|
||||||
fun getLocalPosts(): Flow<List<LocalPost>> = dao.getAllPosts()
|
fun getLocalPosts(): Flow<List<LocalPost>> {
|
||||||
|
val accountId = getActiveAccountId()
|
||||||
|
return if (accountId.isNotEmpty()) {
|
||||||
|
dao.getPostsByAccount(accountId)
|
||||||
|
} else {
|
||||||
|
dao.getAllPosts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getQueuedPosts(): List<LocalPost> = dao.getQueuedPosts()
|
suspend fun getQueuedPosts(): List<LocalPost> = dao.getQueuedPosts()
|
||||||
|
|
||||||
suspend fun saveLocalPost(post: LocalPost): Long = dao.insertPost(post)
|
suspend fun saveLocalPost(post: LocalPost): Long {
|
||||||
|
val postWithAccount = if (post.accountId.isEmpty()) {
|
||||||
|
post.copy(accountId = getActiveAccountId())
|
||||||
|
} else {
|
||||||
|
post
|
||||||
|
}
|
||||||
|
return dao.insertPost(postWithAccount)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun updateLocalPost(post: LocalPost) = dao.updatePost(post)
|
suspend fun updateLocalPost(post: LocalPost) = dao.updatePost(post)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
package com.swoosh.microblog.ui.feed
|
package com.swoosh.microblog.ui.feed
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.WifiOff
|
import androidx.compose.material.icons.filled.WifiOff
|
||||||
|
|
@ -19,6 +25,7 @@ import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
@ -27,6 +34,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.swoosh.microblog.data.model.FeedPost
|
import com.swoosh.microblog.data.model.FeedPost
|
||||||
|
import com.swoosh.microblog.data.model.GhostAccount
|
||||||
import com.swoosh.microblog.data.model.QueueStatus
|
import com.swoosh.microblog.data.model.QueueStatus
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||||
|
|
@ -35,11 +43,18 @@ fun FeedScreen(
|
||||||
onSettingsClick: () -> Unit,
|
onSettingsClick: () -> Unit,
|
||||||
onPostClick: (FeedPost) -> Unit,
|
onPostClick: (FeedPost) -> Unit,
|
||||||
onCompose: () -> Unit,
|
onCompose: () -> Unit,
|
||||||
|
onAddAccount: () -> Unit = {},
|
||||||
viewModel: FeedViewModel = viewModel()
|
viewModel: FeedViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||||
|
val activeAccount by viewModel.activeAccount.collectAsStateWithLifecycle()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
var showAccountSwitcher by remember { mutableStateOf(false) }
|
||||||
|
var showDeleteConfirmation by remember { mutableStateOf<GhostAccount?>(null) }
|
||||||
|
var showRenameDialog by remember { mutableStateOf<GhostAccount?>(null) }
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh
|
||||||
val pullRefreshState = rememberPullRefreshState(
|
val pullRefreshState = rememberPullRefreshState(
|
||||||
refreshing = state.isRefreshing,
|
refreshing = state.isRefreshing,
|
||||||
|
|
@ -63,7 +78,51 @@ fun FeedScreen(
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Swoosh") },
|
title = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.clickable { showAccountSwitcher = true }
|
||||||
|
) {
|
||||||
|
// Account color indicator
|
||||||
|
if (activeAccount != null) {
|
||||||
|
AccountAvatar(
|
||||||
|
account = activeAccount!!,
|
||||||
|
size = 28
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = activeAccount?.name ?: "Swoosh",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (activeAccount != null) {
|
||||||
|
Text(
|
||||||
|
text = activeAccount!!.blogUrl
|
||||||
|
.removePrefix("https://")
|
||||||
|
.removePrefix("http://")
|
||||||
|
.removeSuffix("/"),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accounts.size > 1 || accounts.isNotEmpty()) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.KeyboardArrowDown,
|
||||||
|
contentDescription = "Switch account",
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { viewModel.refresh() }) {
|
IconButton(onClick = { viewModel.refresh() }) {
|
||||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||||
|
|
@ -86,6 +145,23 @@ fun FeedScreen(
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.pullRefresh(pullRefreshState)
|
.pullRefresh(pullRefreshState)
|
||||||
) {
|
) {
|
||||||
|
// Loading overlay during account switch
|
||||||
|
if (state.isSwitchingAccount) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = "Switching account...",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if (state.posts.isEmpty() && !state.isRefreshing) {
|
if (state.posts.isEmpty() && !state.isRefreshing) {
|
||||||
if (state.isConnectionError && state.error != null) {
|
if (state.isConnectionError && state.error != null) {
|
||||||
// Connection error empty state
|
// Connection error empty state
|
||||||
|
|
@ -166,6 +242,7 @@ fun FeedScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PullRefreshIndicator(
|
PullRefreshIndicator(
|
||||||
refreshing = state.isRefreshing,
|
refreshing = state.isRefreshing,
|
||||||
|
|
@ -189,6 +266,255 @@ fun FeedScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Account switcher bottom sheet
|
||||||
|
if (showAccountSwitcher) {
|
||||||
|
AccountSwitcherBottomSheet(
|
||||||
|
accounts = accounts,
|
||||||
|
activeAccountId = activeAccount?.id,
|
||||||
|
onAccountSelected = { accountId ->
|
||||||
|
showAccountSwitcher = false
|
||||||
|
viewModel.switchAccount(accountId)
|
||||||
|
},
|
||||||
|
onAddAccount = {
|
||||||
|
showAccountSwitcher = false
|
||||||
|
onAddAccount()
|
||||||
|
},
|
||||||
|
onDeleteAccount = { account ->
|
||||||
|
showDeleteConfirmation = account
|
||||||
|
},
|
||||||
|
onRenameAccount = { account ->
|
||||||
|
showRenameDialog = account
|
||||||
|
},
|
||||||
|
onDismiss = { showAccountSwitcher = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete confirmation dialog
|
||||||
|
showDeleteConfirmation?.let { account ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteConfirmation = null },
|
||||||
|
title = { Text("Remove Account") },
|
||||||
|
text = { Text("Remove \"${account.name}\"? Local drafts for this account will be kept.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.removeAccount(account.id)
|
||||||
|
showDeleteConfirmation = null
|
||||||
|
if (accounts.size <= 1) {
|
||||||
|
showAccountSwitcher = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) { Text("Remove") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDeleteConfirmation = null }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename dialog
|
||||||
|
showRenameDialog?.let { account ->
|
||||||
|
var newName by remember { mutableStateOf(account.name) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showRenameDialog = null },
|
||||||
|
title = { Text("Rename Account") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newName,
|
||||||
|
onValueChange = { newName = it },
|
||||||
|
label = { Text("Account Name") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (newName.isNotBlank()) {
|
||||||
|
viewModel.renameAccount(account.id, newName)
|
||||||
|
showRenameDialog = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { Text("Save") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showRenameDialog = null }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AccountSwitcherBottomSheet(
|
||||||
|
accounts: List<GhostAccount>,
|
||||||
|
activeAccountId: String?,
|
||||||
|
onAccountSelected: (String) -> Unit,
|
||||||
|
onAddAccount: () -> Unit,
|
||||||
|
onDeleteAccount: (GhostAccount) -> Unit,
|
||||||
|
onRenameAccount: (GhostAccount) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState()
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 32.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Accounts",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
accounts.forEach { account ->
|
||||||
|
AccountListItem(
|
||||||
|
account = account,
|
||||||
|
isActive = account.id == activeAccountId,
|
||||||
|
onClick = { onAccountSelected(account.id) },
|
||||||
|
onDelete = { onDeleteAccount(account) },
|
||||||
|
onRename = { onRenameAccount(account) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accounts.size < 5) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Add account") },
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
contentDescription = "Add account",
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable(onClick = onAddAccount)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AccountListItem(
|
||||||
|
account: GhostAccount,
|
||||||
|
isActive: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
onRename: () -> Unit
|
||||||
|
) {
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
account.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = if (isActive) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
account.blogUrl
|
||||||
|
.removePrefix("https://")
|
||||||
|
.removePrefix("http://")
|
||||||
|
.removeSuffix("/"),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
AccountAvatar(account = account, size = 36)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (isActive) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = "Active",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { showMenu = true }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Edit,
|
||||||
|
contentDescription = "More options",
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMenu,
|
||||||
|
onDismissRequest = { showMenu = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Rename") },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onRename()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Remove", color = MaterialTheme.colorScheme.error) },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onDelete()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable(onClick = onClick)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AccountAvatar(
|
||||||
|
account: GhostAccount,
|
||||||
|
size: Int = 36
|
||||||
|
) {
|
||||||
|
val color = Color(GhostAccount.colorForIndex(account.colorIndex))
|
||||||
|
val initial = account.name.firstOrNull()?.uppercase() ?: "?"
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(size.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = initial,
|
||||||
|
color = Color.White,
|
||||||
|
style = if (size <= 28) MaterialTheme.typography.labelSmall
|
||||||
|
else MaterialTheme.typography.labelLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@ package com.swoosh.microblog.ui.feed
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.swoosh.microblog.data.AccountManager
|
||||||
|
import com.swoosh.microblog.data.api.ApiClient
|
||||||
import com.swoosh.microblog.data.model.*
|
import com.swoosh.microblog.data.model.*
|
||||||
import com.swoosh.microblog.data.repository.PostRepository
|
import com.swoosh.microblog.data.repository.PostRepository
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.net.ConnectException
|
import java.net.ConnectException
|
||||||
|
|
@ -18,22 +21,37 @@ import javax.net.ssl.SSLException
|
||||||
|
|
||||||
class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val repository = PostRepository(application)
|
private val accountManager = AccountManager(application)
|
||||||
|
private var repository = PostRepository(application)
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(FeedUiState())
|
private val _uiState = MutableStateFlow(FeedUiState())
|
||||||
val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _accounts = MutableStateFlow<List<GhostAccount>>(emptyList())
|
||||||
|
val accounts: StateFlow<List<GhostAccount>> = _accounts.asStateFlow()
|
||||||
|
|
||||||
|
private val _activeAccount = MutableStateFlow<GhostAccount?>(null)
|
||||||
|
val activeAccount: StateFlow<GhostAccount?> = _activeAccount.asStateFlow()
|
||||||
|
|
||||||
private var currentPage = 1
|
private var currentPage = 1
|
||||||
private var hasMorePages = true
|
private var hasMorePages = true
|
||||||
private var remotePosts = listOf<FeedPost>()
|
private var remotePosts = listOf<FeedPost>()
|
||||||
|
private var localPostsJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
refreshAccountsList()
|
||||||
observeLocalPosts()
|
observeLocalPosts()
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun refreshAccountsList() {
|
||||||
|
_accounts.value = accountManager.getAccounts()
|
||||||
|
_activeAccount.value = accountManager.getActiveAccount()
|
||||||
|
}
|
||||||
|
|
||||||
private fun observeLocalPosts() {
|
private fun observeLocalPosts() {
|
||||||
viewModelScope.launch {
|
localPostsJob?.cancel()
|
||||||
|
localPostsJob = viewModelScope.launch {
|
||||||
repository.getLocalPosts().collect { localPosts ->
|
repository.getLocalPosts().collect { localPosts ->
|
||||||
val queuedPosts = localPosts
|
val queuedPosts = localPosts
|
||||||
.filter { it.queueStatus != QueueStatus.NONE }
|
.filter { it.queueStatus != QueueStatus.NONE }
|
||||||
|
|
@ -43,6 +61,64 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun switchAccount(accountId: String) {
|
||||||
|
if (accountId == _activeAccount.value?.id) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isSwitchingAccount = true) }
|
||||||
|
|
||||||
|
accountManager.setActiveAccount(accountId)
|
||||||
|
ApiClient.reset()
|
||||||
|
|
||||||
|
// Re-create repository to pick up new account
|
||||||
|
repository = PostRepository(getApplication())
|
||||||
|
|
||||||
|
refreshAccountsList()
|
||||||
|
|
||||||
|
// Clear current state
|
||||||
|
remotePosts = emptyList()
|
||||||
|
currentPage = 1
|
||||||
|
hasMorePages = true
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
posts = emptyList(),
|
||||||
|
error = null,
|
||||||
|
isConnectionError = false,
|
||||||
|
isSwitchingAccount = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-observe local posts for new account
|
||||||
|
observeLocalPosts()
|
||||||
|
|
||||||
|
// Refresh from new account's API
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAccount(accountId: String) {
|
||||||
|
val wasActive = _activeAccount.value?.id == accountId
|
||||||
|
accountManager.removeAccount(accountId)
|
||||||
|
refreshAccountsList()
|
||||||
|
|
||||||
|
if (wasActive) {
|
||||||
|
val nextAccount = accountManager.getActiveAccount()
|
||||||
|
if (nextAccount != null) {
|
||||||
|
switchAccount(nextAccount.id)
|
||||||
|
} else {
|
||||||
|
// No accounts left
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(posts = emptyList(), error = null, isConnectionError = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renameAccount(accountId: String, newName: String) {
|
||||||
|
accountManager.updateAccount(accountId, name = newName)
|
||||||
|
refreshAccountsList()
|
||||||
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isRefreshing = true, error = null, isConnectionError = false) }
|
_uiState.update { it.copy(isRefreshing = true, error = null, isConnectionError = false) }
|
||||||
|
|
@ -179,6 +255,7 @@ data class FeedUiState(
|
||||||
val posts: List<FeedPost> = emptyList(),
|
val posts: List<FeedPost> = emptyList(),
|
||||||
val isRefreshing: Boolean = false,
|
val isRefreshing: Boolean = false,
|
||||||
val isLoadingMore: Boolean = false,
|
val isLoadingMore: Boolean = false,
|
||||||
|
val isSwitchingAccount: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val isConnectionError: Boolean = false
|
val isConnectionError: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ object Routes {
|
||||||
const val COMPOSER = "composer"
|
const val COMPOSER = "composer"
|
||||||
const val DETAIL = "detail"
|
const val DETAIL = "detail"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
|
const val ADD_ACCOUNT = "add_account"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -55,6 +56,9 @@ fun SwooshNavGraph(
|
||||||
onCompose = {
|
onCompose = {
|
||||||
editPost = null
|
editPost = null
|
||||||
navController.navigate(Routes.COMPOSER)
|
navController.navigate(Routes.COMPOSER)
|
||||||
|
},
|
||||||
|
onAddAccount = {
|
||||||
|
navController.navigate(Routes.ADD_ACCOUNT)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +95,11 @@ fun SwooshNavGraph(
|
||||||
|
|
||||||
composable(Routes.SETTINGS) {
|
composable(Routes.SETTINGS) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBack = { navController.popBackStack() },
|
onBack = {
|
||||||
|
feedViewModel.refreshAccountsList()
|
||||||
|
feedViewModel.refresh()
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
onLogout = {
|
onLogout = {
|
||||||
navController.navigate(Routes.SETUP) {
|
navController.navigate(Routes.SETUP) {
|
||||||
popUpTo(0) { inclusive = true }
|
popUpTo(0) { inclusive = true }
|
||||||
|
|
@ -99,5 +107,18 @@ fun SwooshNavGraph(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(Routes.ADD_ACCOUNT) {
|
||||||
|
SetupScreen(
|
||||||
|
onSetupComplete = {
|
||||||
|
feedViewModel.refreshAccountsList()
|
||||||
|
feedViewModel.refresh()
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
onBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
package com.swoosh.microblog.ui.settings
|
package com.swoosh.microblog.ui.settings
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.swoosh.microblog.data.CredentialsManager
|
import com.swoosh.microblog.data.AccountManager
|
||||||
import com.swoosh.microblog.data.api.ApiClient
|
import com.swoosh.microblog.data.api.ApiClient
|
||||||
|
import com.swoosh.microblog.ui.feed.AccountAvatar
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -21,10 +19,8 @@ fun SettingsScreen(
|
||||||
onLogout: () -> Unit
|
onLogout: () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val credentials = remember { CredentialsManager(context) }
|
val accountManager = remember { AccountManager(context) }
|
||||||
var url by remember { mutableStateOf(credentials.ghostUrl ?: "") }
|
val activeAccount = remember { accountManager.getActiveAccount() }
|
||||||
var apiKey by remember { mutableStateOf(credentials.adminApiKey ?: "") }
|
|
||||||
var saved by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|
@ -44,68 +40,93 @@ fun SettingsScreen(
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(24.dp)
|
.padding(24.dp)
|
||||||
) {
|
) {
|
||||||
Text("Ghost Instance", style = MaterialTheme.typography.titleMedium)
|
// Active account info
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Text("Current Account", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = url,
|
|
||||||
onValueChange = { url = it; saved = false },
|
|
||||||
label = { Text("Ghost URL") },
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
if (activeAccount != null) {
|
||||||
value = apiKey,
|
Card(
|
||||||
onValueChange = { apiKey = it; saved = false },
|
|
||||||
label = { Text("Admin API Key") },
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
credentials.ghostUrl = url
|
|
||||||
credentials.adminApiKey = apiKey
|
|
||||||
ApiClient.reset()
|
|
||||||
saved = true
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text("Save Changes")
|
Row(
|
||||||
}
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
if (saved) {
|
.padding(16.dp),
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
AccountAvatar(account = activeAccount, size = 40)
|
||||||
|
Column {
|
||||||
Text(
|
Text(
|
||||||
"Settings saved",
|
text = activeAccount.name,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
style = MaterialTheme.typography.bodyLarge
|
||||||
style = MaterialTheme.typography.bodySmall
|
)
|
||||||
|
Text(
|
||||||
|
text = activeAccount.blogUrl
|
||||||
|
.removePrefix("https://")
|
||||||
|
.removePrefix("http://")
|
||||||
|
.removeSuffix("/"),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Divider()
|
|
||||||
|
// Account count info
|
||||||
|
val accountCount = accountManager.getAccounts().size
|
||||||
|
Text(
|
||||||
|
text = "$accountCount of ${AccountManager.MAX_ACCOUNTS} accounts",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
HorizontalDivider()
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Disconnect current account
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
credentials.clear()
|
activeAccount?.let { account ->
|
||||||
|
accountManager.removeAccount(account.id)
|
||||||
ApiClient.reset()
|
ApiClient.reset()
|
||||||
|
val remaining = accountManager.getAccounts()
|
||||||
|
if (remaining.isEmpty()) {
|
||||||
onLogout()
|
onLogout()
|
||||||
|
} else {
|
||||||
|
// There are still accounts, go back to feed with the next one
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
contentColor = MaterialTheme.colorScheme.error
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Text("Disconnect & Reset")
|
Text("Disconnect Current Account")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Disconnect all
|
||||||
|
if (accountManager.getAccounts().size > 1) {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
accountManager.clearAll()
|
||||||
|
ApiClient.reset()
|
||||||
|
onLogout()
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Disconnect All Accounts")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.outlined.Info
|
import androidx.compose.material.icons.outlined.Info
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
|
@ -38,6 +39,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
@Composable
|
@Composable
|
||||||
fun SetupScreen(
|
fun SetupScreen(
|
||||||
onSetupComplete: () -> Unit,
|
onSetupComplete: () -> Unit,
|
||||||
|
onBack: (() -> Unit)? = null,
|
||||||
viewModel: SetupViewModel = viewModel()
|
viewModel: SetupViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
@ -72,7 +74,20 @@ fun SetupScreen(
|
||||||
"$withScheme/ghost/#/settings/integrations"
|
"$withScheme/ghost/#/settings/integrations"
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold { padding ->
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
if (state.isAddingAccount && onBack != null) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Add Account") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -100,7 +115,7 @@ fun SetupScreen(
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(
|
Text(
|
||||||
text = "Swoosh",
|
text = if (state.isAddingAccount) "Add Account" else "Swoosh",
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
|
|
@ -108,7 +123,8 @@ fun SetupScreen(
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Connect to your Ghost instance",
|
text = if (state.isAddingAccount) "Connect another Ghost instance"
|
||||||
|
else "Connect to your Ghost instance",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
@ -123,6 +139,18 @@ fun SetupScreen(
|
||||||
.padding(bottom = 32.dp),
|
.padding(bottom = 32.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
|
// Account name field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.accountName,
|
||||||
|
onValueChange = viewModel::updateAccountName,
|
||||||
|
label = { Text("Account Name (optional)") },
|
||||||
|
placeholder = { Text("e.g., Personal Blog") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.url,
|
value = state.url,
|
||||||
onValueChange = viewModel::updateUrl,
|
onValueChange = viewModel::updateUrl,
|
||||||
|
|
@ -219,7 +247,7 @@ fun SetupScreen(
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text("Testing connection...")
|
Text("Testing connection...")
|
||||||
} else {
|
} else {
|
||||||
Text("Connect")
|
Text(if (state.isAddingAccount) "Add Account" else "Connect")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -288,7 +316,7 @@ private fun PulsingCirclesBackground(
|
||||||
val w = size.width
|
val w = size.width
|
||||||
val h = size.height
|
val h = size.height
|
||||||
|
|
||||||
// Circle 1 — primary, upper-left
|
// Circle 1 -- primary, upper-left
|
||||||
val radius1 = w * 0.50f * scale1
|
val radius1 = w * 0.50f * scale1
|
||||||
drawCircle(
|
drawCircle(
|
||||||
brush = Brush.radialGradient(
|
brush = Brush.radialGradient(
|
||||||
|
|
@ -300,7 +328,7 @@ private fun PulsingCirclesBackground(
|
||||||
center = Offset(w * 0.3f, h * 0.35f)
|
center = Offset(w * 0.3f, h * 0.35f)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Circle 2 — tertiary, right
|
// Circle 2 -- tertiary, right
|
||||||
val radius2 = w * 0.55f * scale2
|
val radius2 = w * 0.55f * scale2
|
||||||
drawCircle(
|
drawCircle(
|
||||||
brush = Brush.radialGradient(
|
brush = Brush.radialGradient(
|
||||||
|
|
@ -312,7 +340,7 @@ private fun PulsingCirclesBackground(
|
||||||
center = Offset(w * 0.75f, h * 0.45f)
|
center = Offset(w * 0.75f, h * 0.45f)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Circle 3 — secondary, bottom-center
|
// Circle 3 -- secondary, bottom-center
|
||||||
val radius3 = w * 0.45f * scale3
|
val radius3 = w * 0.45f * scale3
|
||||||
drawCircle(
|
drawCircle(
|
||||||
brush = Brush.radialGradient(
|
brush = Brush.radialGradient(
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package com.swoosh.microblog.ui.setup
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.swoosh.microblog.data.CredentialsManager
|
import com.swoosh.microblog.data.AccountManager
|
||||||
import com.swoosh.microblog.data.api.ApiClient
|
import com.swoosh.microblog.data.api.ApiClient
|
||||||
import com.swoosh.microblog.data.api.GhostJwtGenerator
|
import com.swoosh.microblog.data.api.GhostJwtGenerator
|
||||||
import com.swoosh.microblog.data.repository.PostRepository
|
import com.swoosh.microblog.data.repository.PostRepository
|
||||||
|
|
@ -15,11 +15,10 @@ import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class SetupViewModel(application: Application) : AndroidViewModel(application) {
|
class SetupViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val credentials = CredentialsManager(application)
|
private val accountManager = AccountManager(application)
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(SetupUiState(
|
private val _uiState = MutableStateFlow(SetupUiState(
|
||||||
url = credentials.ghostUrl ?: "",
|
isAddingAccount = accountManager.hasAnyAccount
|
||||||
apiKey = credentials.adminApiKey ?: ""
|
|
||||||
))
|
))
|
||||||
val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
|
@ -31,6 +30,10 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
_uiState.update { it.copy(apiKey = key) }
|
_uiState.update { it.copy(apiKey = key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateAccountName(name: String) {
|
||||||
|
_uiState.update { it.copy(accountName = name) }
|
||||||
|
}
|
||||||
|
|
||||||
fun save() {
|
fun save() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
if (state.url.isBlank() || state.apiKey.isBlank()) {
|
if (state.url.isBlank() || state.apiKey.isBlank()) {
|
||||||
|
|
@ -48,22 +51,39 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isTesting = true, error = null) }
|
_uiState.update { it.copy(isTesting = true, error = null) }
|
||||||
|
|
||||||
// Test connection
|
|
||||||
try {
|
try {
|
||||||
|
// Validate the API key can generate a JWT
|
||||||
GhostJwtGenerator.generateToken(state.apiKey)
|
GhostJwtGenerator.generateToken(state.apiKey)
|
||||||
credentials.ghostUrl = state.url
|
|
||||||
credentials.adminApiKey = state.apiKey
|
// Add account
|
||||||
|
val result = accountManager.addAccount(
|
||||||
|
name = state.accountName,
|
||||||
|
blogUrl = state.url,
|
||||||
|
apiKey = state.apiKey
|
||||||
|
)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { account ->
|
||||||
|
// Reset ApiClient to pick up new account
|
||||||
ApiClient.reset()
|
ApiClient.reset()
|
||||||
|
|
||||||
|
// Test connection
|
||||||
val repo = PostRepository(getApplication())
|
val repo = PostRepository(getApplication())
|
||||||
repo.fetchPosts(page = 1, limit = 1).fold(
|
repo.fetchPosts(page = 1, limit = 1).fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
_uiState.update { it.copy(isTesting = false, isSuccess = true) }
|
_uiState.update { it.copy(isTesting = false, isSuccess = true) }
|
||||||
},
|
},
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
|
// Remove the account since connection failed
|
||||||
|
accountManager.removeAccount(account.id)
|
||||||
_uiState.update { it.copy(isTesting = false, error = "Connection failed: ${e.message}") }
|
_uiState.update { it.copy(isTesting = false, error = "Connection failed: ${e.message}") }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_uiState.update { it.copy(isTesting = false, error = e.message) }
|
||||||
|
}
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.update { it.copy(isTesting = false, error = "Invalid API key: ${e.message}") }
|
_uiState.update { it.copy(isTesting = false, error = "Invalid API key: ${e.message}") }
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +94,9 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
data class SetupUiState(
|
data class SetupUiState(
|
||||||
val url: String = "",
|
val url: String = "",
|
||||||
val apiKey: String = "",
|
val apiKey: String = "",
|
||||||
|
val accountName: String = "",
|
||||||
val isTesting: Boolean = false,
|
val isTesting: Boolean = false,
|
||||||
val isSuccess: Boolean = false,
|
val isSuccess: Boolean = false,
|
||||||
|
val isAddingAccount: Boolean = false,
|
||||||
val error: String? = null
|
val error: String? = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,450 @@
|
||||||
|
package com.swoosh.microblog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import com.swoosh.microblog.data.model.GhostAccount
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [28], application = android.app.Application::class)
|
||||||
|
class AccountManagerTest {
|
||||||
|
|
||||||
|
private lateinit var prefs: SharedPreferences
|
||||||
|
private lateinit var accountManager: AccountManager
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val context = RuntimeEnvironment.getApplication()
|
||||||
|
prefs = context.getSharedPreferences("test_prefs", Context.MODE_PRIVATE)
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
accountManager = AccountManager(prefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CRUD: Add Account ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addAccount creates account with correct fields`() {
|
||||||
|
val result = accountManager.addAccount("Personal Blog", "https://blog.example.com", "key_id:secret")
|
||||||
|
assertTrue(result.isSuccess)
|
||||||
|
val account = result.getOrNull()!!
|
||||||
|
assertEquals("Personal Blog", account.name)
|
||||||
|
assertEquals("https://blog.example.com", account.blogUrl)
|
||||||
|
assertEquals("key_id:secret", account.apiKey)
|
||||||
|
assertTrue(account.id.isNotBlank())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addAccount sets new account as active`() {
|
||||||
|
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:secret1")
|
||||||
|
val active = accountManager.getActiveAccount()
|
||||||
|
assertNotNull(active)
|
||||||
|
assertEquals("Blog 1", active!!.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addAccount with empty name uses extracted blog name`() {
|
||||||
|
val result = accountManager.addAccount("", "https://myblog.ghost.io", "key:secret")
|
||||||
|
assertTrue(result.isSuccess)
|
||||||
|
assertEquals("Myblog", result.getOrNull()!!.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addAccount assigns unique color indices`() {
|
||||||
|
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
|
||||||
|
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
|
||||||
|
accountManager.addAccount("Blog 3", "https://blog3.com", "key3:s3")
|
||||||
|
|
||||||
|
val accounts = accountManager.getAccounts()
|
||||||
|
val colors = accounts.map { it.colorIndex }.toSet()
|
||||||
|
assertEquals("Each account should have a unique color", 3, colors.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addAccount generates unique ids`() {
|
||||||
|
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
|
||||||
|
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
|
||||||
|
|
||||||
|
val accounts = accountManager.getAccounts()
|
||||||
|
val ids = accounts.map { it.id }.toSet()
|
||||||
|
assertEquals(2, ids.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CRUD: Get Accounts ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getAccounts returns empty list initially`() {
|
||||||
|
assertEquals(emptyList<GhostAccount>(), accountManager.getAccounts())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getAccounts returns all added accounts`() {
|
||||||
|
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
|
||||||
|
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
|
||||||
|
|
||||||
|
val accounts = accountManager.getAccounts()
|
||||||
|
assertEquals(2, accounts.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getAccounts marks active account correctly`() {
|
||||||
|
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
|
||||||
|
val result2 = accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
|
||||||
|
|
||||||
|
val accounts = accountManager.getAccounts()
|
||||||
|
val activeAccount = accounts.find { it.isActive }
|
||||||
|
assertNotNull(activeAccount)
|
||||||
|
assertEquals(result2.getOrNull()!!.id, activeAccount!!.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CRUD: Get Active Account ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getActiveAccount returns null when no accounts`() {
|
||||||
|
assertNull(accountManager.getActiveAccount())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getActiveAccount returns the set active account`() {
|
||||||
|
val result1 = accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
|
||||||
|
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
|
||||||
|
|
||||||
|
accountManager.setActiveAccount(result1.getOrNull()!!.id)
|
||||||
|
val active = accountManager.getActiveAccount()
|
||||||
|
assertEquals("Blog 1", active!!.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CRUD: Set Active Account ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setActiveAccount returns true for valid id`() {
|
||||||
|
val result = accountManager.addAccount("Blog", "https://blog.com", "key:s")
|
||||||
|
assertTrue(accountManager.setActiveAccount(result.getOrNull()!!.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setActiveAccount returns false for invalid id`() {
|
||||||
|
assertFalse(accountManager.setActiveAccount("nonexistent-id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setActiveAccount switches active account`() {
|
||||||
|
val result1 = accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
|
||||||
|
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
|
||||||
|
|
||||||
|
accountManager.setActiveAccount(result1.getOrNull()!!.id)
|
||||||
|
val active = accountManager.getActiveAccount()
|
||||||
|
assertEquals(result1.getOrNull()!!.id, active!!.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CRUD: Remove Account ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removeAccount removes the specified account`() {
|
||||||
|
val result = accountManager.addAccount("Blog", "https://blog.com", "key:s")
|
||||||
|
assertTrue(accountManager.removeAccount(result.getOrNull()!!.id))
|
||||||
|
assertEquals(0, accountManager.getAccounts().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removeAccount returns false for nonexistent id`() {
|
||||||
|
assertFalse(accountManager.removeAccount("nonexistent"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removeAccount switches active to next account when removing active`() {
|
||||||
|
val result1 = accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
|
||||||
|
val result2 = accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
|
||||||
|
// Blog 2 is now active (most recently added)
|
||||||
|
|
||||||
|
accountManager.removeAccount(result2.getOrNull()!!.id)
|
||||||
|
val active = accountManager.getActiveAccount()
|
||||||
|
assertNotNull(active)
|
||||||
|
assertEquals(result1.getOrNull()!!.id, active!!.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removeAccount clears active when last account removed`() {
|
||||||
|
val result = accountManager.addAccount("Blog", "https://blog.com", "key:s")
|
||||||
|
accountManager.removeAccount(result.getOrNull()!!.id)
|
||||||
|
assertNull(accountManager.getActiveAccount())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removeAccount does not change active when removing non-active`() {
|
||||||
|
val result1 = accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
|
||||||
|
val result2 = accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
|
||||||
|
// Blog 2 is active
|
||||||
|
|
||||||
|
accountManager.removeAccount(result1.getOrNull()!!.id)
|
||||||
|
val active = accountManager.getActiveAccount()
|
||||||
|
assertEquals(result2.getOrNull()!!.id, active!!.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CRUD: Update Account ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateAccount updates name`() {
|
||||||
|
val result = accountManager.addAccount("Old Name", "https://blog.com", "key:s")
|
||||||
|
assertTrue(accountManager.updateAccount(result.getOrNull()!!.id, name = "New Name"))
|
||||||
|
val updated = accountManager.getAccounts().find { it.id == result.getOrNull()!!.id }
|
||||||
|
assertEquals("New Name", updated!!.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateAccount updates blogUrl`() {
|
||||||
|
val result = accountManager.addAccount("Blog", "https://old.com", "key:s")
|
||||||
|
assertTrue(accountManager.updateAccount(result.getOrNull()!!.id, blogUrl = "https://new.com"))
|
||||||
|
val updated = accountManager.getAccounts().find { it.id == result.getOrNull()!!.id }
|
||||||
|
assertEquals("https://new.com", updated!!.blogUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateAccount updates apiKey`() {
|
||||||
|
val result = accountManager.addAccount("Blog", "https://blog.com", "old_key:secret")
|
||||||
|
assertTrue(accountManager.updateAccount(result.getOrNull()!!.id, apiKey = "new_key:secret"))
|
||||||
|
val updated = accountManager.getAccounts().find { it.id == result.getOrNull()!!.id }
|
||||||
|
assertEquals("new_key:secret", updated!!.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateAccount returns false for nonexistent id`() {
|
||||||
|
assertFalse(accountManager.updateAccount("nonexistent", name = "Name"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateAccount preserves unchanged fields`() {
|
||||||
|
val result = accountManager.addAccount("Blog", "https://blog.com", "key:s")
|
||||||
|
accountManager.updateAccount(result.getOrNull()!!.id, name = "New Name")
|
||||||
|
val updated = accountManager.getAccounts().find { it.id == result.getOrNull()!!.id }
|
||||||
|
assertEquals("https://blog.com", updated!!.blogUrl)
|
||||||
|
assertEquals("key:s", updated.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Max Accounts Limit ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `addAccount fails when max accounts reached`() {
|
||||||
|
for (i in 1..5) {
|
||||||
|
val r = accountManager.addAccount("Blog $i", "https://blog$i.com", "key$i:s$i")
|
||||||
|
assertTrue("Account $i should be added successfully", r.isSuccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = accountManager.addAccount("Blog 6", "https://blog6.com", "key6:s6")
|
||||||
|
assertTrue(result.isFailure)
|
||||||
|
assertTrue(result.exceptionOrNull()!!.message!!.contains("Maximum"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `max accounts is 5`() {
|
||||||
|
assertEquals(5, AccountManager.MAX_ACCOUNTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `can add account after removing one at max`() {
|
||||||
|
val accountIds = mutableListOf<String>()
|
||||||
|
for (i in 1..5) {
|
||||||
|
val r = accountManager.addAccount("Blog $i", "https://blog$i.com", "key$i:s$i")
|
||||||
|
accountIds.add(r.getOrNull()!!.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountManager.removeAccount(accountIds[0])
|
||||||
|
val result = accountManager.addAccount("Blog 6", "https://blog6.com", "key6:s6")
|
||||||
|
assertTrue(result.isSuccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Migration ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `migration from single account creates account entry`() {
|
||||||
|
// Simulate legacy state: write directly to prefs
|
||||||
|
prefs.edit()
|
||||||
|
.putString("ghost_url", "https://legacy.ghost.io")
|
||||||
|
.putString("admin_api_key", "legacy_key:legacy_secret")
|
||||||
|
.remove("multi_account_migration_done")
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
// Create a new AccountManager to trigger migration
|
||||||
|
val freshManager = AccountManager(prefs)
|
||||||
|
val accounts = freshManager.getAccounts()
|
||||||
|
|
||||||
|
assertEquals(1, accounts.size)
|
||||||
|
val account = accounts[0]
|
||||||
|
assertEquals("https://legacy.ghost.io", account.blogUrl)
|
||||||
|
assertEquals("legacy_key:legacy_secret", account.apiKey)
|
||||||
|
assertTrue(account.isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `migration removes legacy keys`() {
|
||||||
|
prefs.edit()
|
||||||
|
.putString("ghost_url", "https://legacy.ghost.io")
|
||||||
|
.putString("admin_api_key", "legacy_key:legacy_secret")
|
||||||
|
.remove("multi_account_migration_done")
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
AccountManager(prefs)
|
||||||
|
// Legacy keys should be removed
|
||||||
|
assertNull(prefs.getString("ghost_url", null))
|
||||||
|
assertNull(prefs.getString("admin_api_key", null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `migration only runs once`() {
|
||||||
|
prefs.edit()
|
||||||
|
.putString("ghost_url", "https://legacy.ghost.io")
|
||||||
|
.putString("admin_api_key", "legacy_key:legacy_secret")
|
||||||
|
.remove("multi_account_migration_done")
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
val manager1 = AccountManager(prefs)
|
||||||
|
assertEquals(1, manager1.getAccounts().size)
|
||||||
|
|
||||||
|
// Second instantiation should not duplicate
|
||||||
|
val manager2 = AccountManager(prefs)
|
||||||
|
assertEquals(1, manager2.getAccounts().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `migration does nothing when no legacy credentials`() {
|
||||||
|
prefs.edit()
|
||||||
|
.remove("ghost_url")
|
||||||
|
.remove("admin_api_key")
|
||||||
|
.remove("multi_account_migration_done")
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
val freshManager = AccountManager(prefs)
|
||||||
|
assertEquals(0, freshManager.getAccounts().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `migration extracts blog name from legacy url`() {
|
||||||
|
prefs.edit()
|
||||||
|
.putString("ghost_url", "https://myblog.ghost.io")
|
||||||
|
.putString("admin_api_key", "key:secret")
|
||||||
|
.remove("multi_account_migration_done")
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
val freshManager = AccountManager(prefs)
|
||||||
|
val account = freshManager.getAccounts().first()
|
||||||
|
assertEquals("Myblog", account.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- isConfigured ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isConfigured returns false when no accounts`() {
|
||||||
|
assertFalse(accountManager.isConfigured)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isConfigured returns true when active account exists`() {
|
||||||
|
accountManager.addAccount("Blog", "https://blog.com", "key:s")
|
||||||
|
assertTrue(accountManager.isConfigured)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hasAnyAccount ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hasAnyAccount returns false when no accounts`() {
|
||||||
|
assertFalse(accountManager.hasAnyAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hasAnyAccount returns true when accounts exist`() {
|
||||||
|
accountManager.addAccount("Blog", "https://blog.com", "key:s")
|
||||||
|
assertTrue(accountManager.hasAnyAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- clearAll ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clearAll removes all accounts`() {
|
||||||
|
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
|
||||||
|
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
|
||||||
|
|
||||||
|
accountManager.clearAll()
|
||||||
|
|
||||||
|
assertEquals(0, accountManager.getAccounts().size)
|
||||||
|
assertNull(accountManager.getActiveAccount())
|
||||||
|
assertFalse(accountManager.isConfigured)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- extractBlogName ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extractBlogName removes ghost io suffix`() {
|
||||||
|
assertEquals("Myblog", accountManager.extractBlogName("https://myblog.ghost.io"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extractBlogName removes com suffix`() {
|
||||||
|
assertEquals("Myblog", accountManager.extractBlogName("https://myblog.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extractBlogName handles URL without scheme`() {
|
||||||
|
assertEquals("Myblog", accountManager.extractBlogName("myblog.ghost.io"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extractBlogName capitalizes first letter`() {
|
||||||
|
assertEquals("Myblog", accountManager.extractBlogName("myblog.ghost.io"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extractBlogName handles trailing slash`() {
|
||||||
|
assertEquals("Myblog", accountManager.extractBlogName("https://myblog.ghost.io/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extractBlogName handles URL with path`() {
|
||||||
|
assertEquals("Myblog", accountManager.extractBlogName("https://myblog.ghost.io/blog"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Account switching ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switching active account changes getActiveAccount result`() {
|
||||||
|
val r1 = accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
|
||||||
|
val r2 = accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
|
||||||
|
|
||||||
|
// Blog 2 is now active
|
||||||
|
assertEquals("Blog 2", accountManager.getActiveAccount()!!.name)
|
||||||
|
|
||||||
|
// Switch to Blog 1
|
||||||
|
accountManager.setActiveAccount(r1.getOrNull()!!.id)
|
||||||
|
assertEquals("Blog 1", accountManager.getActiveAccount()!!.name)
|
||||||
|
|
||||||
|
// Switch back to Blog 2
|
||||||
|
accountManager.setActiveAccount(r2.getOrNull()!!.id)
|
||||||
|
assertEquals("Blog 2", accountManager.getActiveAccount()!!.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only one account is marked active at a time`() {
|
||||||
|
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
|
||||||
|
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
|
||||||
|
accountManager.addAccount("Blog 3", "https://blog3.com", "key3:s3")
|
||||||
|
|
||||||
|
val accounts = accountManager.getAccounts()
|
||||||
|
val activeCount = accounts.count { it.isActive }
|
||||||
|
assertEquals(1, activeCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `persisted data survives new AccountManager instance`() {
|
||||||
|
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
|
||||||
|
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
|
||||||
|
|
||||||
|
// Create a new instance sharing the same prefs
|
||||||
|
val newManager = AccountManager(prefs)
|
||||||
|
assertEquals(2, newManager.getAccounts().size)
|
||||||
|
assertEquals("Blog 2", newManager.getActiveAccount()!!.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
package com.swoosh.microblog.data.db
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import com.swoosh.microblog.data.model.LocalPost
|
||||||
|
import com.swoosh.microblog.data.model.PostStatus
|
||||||
|
import com.swoosh.microblog.data.model.QueueStatus
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [28], application = android.app.Application::class)
|
||||||
|
class LocalPostDaoTest {
|
||||||
|
|
||||||
|
private lateinit var database: AppDatabase
|
||||||
|
private lateinit var dao: LocalPostDao
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val context: Context = RuntimeEnvironment.getApplication()
|
||||||
|
database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
|
||||||
|
.allowMainThreadQueries()
|
||||||
|
.build()
|
||||||
|
dao = database.localPostDao()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
database.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Account-filtered queries ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getPostsByAccount returns only posts for specified account`() = runTest {
|
||||||
|
val post1 = LocalPost(
|
||||||
|
accountId = "account-1",
|
||||||
|
title = "Post for account 1",
|
||||||
|
content = "Content 1"
|
||||||
|
)
|
||||||
|
val post2 = LocalPost(
|
||||||
|
accountId = "account-2",
|
||||||
|
title = "Post for account 2",
|
||||||
|
content = "Content 2"
|
||||||
|
)
|
||||||
|
val post3 = LocalPost(
|
||||||
|
accountId = "account-1",
|
||||||
|
title = "Another post for account 1",
|
||||||
|
content = "Content 3"
|
||||||
|
)
|
||||||
|
|
||||||
|
dao.insertPost(post1)
|
||||||
|
dao.insertPost(post2)
|
||||||
|
dao.insertPost(post3)
|
||||||
|
|
||||||
|
val account1Posts = dao.getPostsByAccount("account-1").first()
|
||||||
|
assertEquals(2, account1Posts.size)
|
||||||
|
assertTrue(account1Posts.all { it.accountId == "account-1" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getPostsByAccount returns empty list for account with no posts`() = runTest {
|
||||||
|
val post = LocalPost(
|
||||||
|
accountId = "account-1",
|
||||||
|
title = "Post",
|
||||||
|
content = "Content"
|
||||||
|
)
|
||||||
|
dao.insertPost(post)
|
||||||
|
|
||||||
|
val posts = dao.getPostsByAccount("account-2").first()
|
||||||
|
assertEquals(0, posts.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getAllPosts returns posts from all accounts`() = runTest {
|
||||||
|
dao.insertPost(LocalPost(accountId = "account-1", title = "P1", content = "C1"))
|
||||||
|
dao.insertPost(LocalPost(accountId = "account-2", title = "P2", content = "C2"))
|
||||||
|
|
||||||
|
val allPosts = dao.getAllPosts().first()
|
||||||
|
assertEquals(2, allPosts.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getQueuedPostsByAccount returns only queued posts for account`() = runTest {
|
||||||
|
dao.insertPost(
|
||||||
|
LocalPost(
|
||||||
|
accountId = "account-1",
|
||||||
|
title = "Queued 1",
|
||||||
|
content = "C1",
|
||||||
|
queueStatus = QueueStatus.QUEUED_PUBLISH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dao.insertPost(
|
||||||
|
LocalPost(
|
||||||
|
accountId = "account-2",
|
||||||
|
title = "Queued 2",
|
||||||
|
content = "C2",
|
||||||
|
queueStatus = QueueStatus.QUEUED_PUBLISH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dao.insertPost(
|
||||||
|
LocalPost(
|
||||||
|
accountId = "account-1",
|
||||||
|
title = "Not queued",
|
||||||
|
content = "C3",
|
||||||
|
queueStatus = QueueStatus.NONE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val queued = dao.getQueuedPostsByAccount("account-1")
|
||||||
|
assertEquals(1, queued.size)
|
||||||
|
assertEquals("Queued 1", queued[0].title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getQueuedPosts returns queued posts from all accounts`() = runTest {
|
||||||
|
dao.insertPost(
|
||||||
|
LocalPost(
|
||||||
|
accountId = "account-1",
|
||||||
|
title = "Q1",
|
||||||
|
content = "C1",
|
||||||
|
queueStatus = QueueStatus.QUEUED_PUBLISH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dao.insertPost(
|
||||||
|
LocalPost(
|
||||||
|
accountId = "account-2",
|
||||||
|
title = "Q2",
|
||||||
|
content = "C2",
|
||||||
|
queueStatus = QueueStatus.QUEUED_SCHEDULED
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val queued = dao.getQueuedPosts()
|
||||||
|
assertEquals(2, queued.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- accountId field on LocalPost ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `LocalPost stores and retrieves accountId`() = runTest {
|
||||||
|
val id = dao.insertPost(
|
||||||
|
LocalPost(
|
||||||
|
accountId = "test-account-id",
|
||||||
|
title = "Test",
|
||||||
|
content = "Content"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val retrieved = dao.getPostById(id)
|
||||||
|
assertNotNull(retrieved)
|
||||||
|
assertEquals("test-account-id", retrieved!!.accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `LocalPost has empty string as default accountId`() {
|
||||||
|
val post = LocalPost(title = "Test", content = "Content")
|
||||||
|
assertEquals("", post.accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getPostsByAccount orders by updatedAt descending`() = runTest {
|
||||||
|
dao.insertPost(
|
||||||
|
LocalPost(
|
||||||
|
accountId = "account-1",
|
||||||
|
title = "Older",
|
||||||
|
content = "C1",
|
||||||
|
updatedAt = 1000L
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dao.insertPost(
|
||||||
|
LocalPost(
|
||||||
|
accountId = "account-1",
|
||||||
|
title = "Newer",
|
||||||
|
content = "C2",
|
||||||
|
updatedAt = 2000L
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val posts = dao.getPostsByAccount("account-1").first()
|
||||||
|
assertEquals("Newer", posts[0].title)
|
||||||
|
assertEquals("Older", posts[1].title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
package com.swoosh.microblog.data.model
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class GhostAccountTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GhostAccount has correct default values`() {
|
||||||
|
val account = GhostAccount(
|
||||||
|
id = "test-id",
|
||||||
|
name = "Test Blog",
|
||||||
|
blogUrl = "https://test.ghost.io",
|
||||||
|
apiKey = "key:secret"
|
||||||
|
)
|
||||||
|
assertFalse(account.isActive)
|
||||||
|
assertEquals(0, account.colorIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GhostAccount preserves all fields`() {
|
||||||
|
val account = GhostAccount(
|
||||||
|
id = "id-123",
|
||||||
|
name = "My Blog",
|
||||||
|
blogUrl = "https://myblog.com",
|
||||||
|
apiKey = "abc:def",
|
||||||
|
isActive = true,
|
||||||
|
colorIndex = 3
|
||||||
|
)
|
||||||
|
assertEquals("id-123", account.id)
|
||||||
|
assertEquals("My Blog", account.name)
|
||||||
|
assertEquals("https://myblog.com", account.blogUrl)
|
||||||
|
assertEquals("abc:def", account.apiKey)
|
||||||
|
assertTrue(account.isActive)
|
||||||
|
assertEquals(3, account.colorIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ACCOUNT_COLORS has 5 colors`() {
|
||||||
|
assertEquals(5, GhostAccount.ACCOUNT_COLORS.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `colorForIndex wraps around for large indices`() {
|
||||||
|
val color0 = GhostAccount.colorForIndex(0)
|
||||||
|
val color5 = GhostAccount.colorForIndex(5)
|
||||||
|
assertEquals(color0, color5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `colorForIndex returns different colors for different indices`() {
|
||||||
|
val colors = (0 until 5).map { GhostAccount.colorForIndex(it) }.toSet()
|
||||||
|
assertEquals(5, colors.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `copy preserves fields correctly`() {
|
||||||
|
val original = GhostAccount(
|
||||||
|
id = "id",
|
||||||
|
name = "Name",
|
||||||
|
blogUrl = "https://blog.com",
|
||||||
|
apiKey = "key:secret",
|
||||||
|
isActive = true,
|
||||||
|
colorIndex = 2
|
||||||
|
)
|
||||||
|
val copy = original.copy(name = "New Name")
|
||||||
|
assertEquals("New Name", copy.name)
|
||||||
|
assertEquals("id", copy.id)
|
||||||
|
assertEquals("https://blog.com", copy.blogUrl)
|
||||||
|
assertEquals("key:secret", copy.apiKey)
|
||||||
|
assertTrue(copy.isActive)
|
||||||
|
assertEquals(2, copy.colorIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue