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.enableEdgeToEdge
|
||||
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.SwooshNavGraph
|
||||
import com.swoosh.microblog.ui.theme.SwooshTheme
|
||||
|
|
@ -15,8 +15,8 @@ class MainActivity : ComponentActivity() {
|
|||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
val credentials = CredentialsManager(this)
|
||||
val startDestination = if (credentials.isConfigured) Routes.FEED else Routes.SETUP
|
||||
val accountManager = AccountManager(this)
|
||||
val startDestination = if (accountManager.isConfigured) Routes.FEED else Routes.SETUP
|
||||
|
||||
setContent {
|
||||
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
|
||||
|
||||
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) {
|
||||
|
||||
private val masterKey = MasterKey.Builder(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
|
||||
)
|
||||
private val accountManager = AccountManager(context)
|
||||
|
||||
var ghostUrl: String?
|
||||
get() = prefs.getString(KEY_GHOST_URL, null)
|
||||
set(value) = prefs.edit().putString(KEY_GHOST_URL, value).apply()
|
||||
get() = accountManager.getActiveAccount()?.blogUrl
|
||||
set(_) {
|
||||
// No-op: use AccountManager directly for mutations
|
||||
}
|
||||
|
||||
var adminApiKey: String?
|
||||
get() = prefs.getString(KEY_ADMIN_API_KEY, null)
|
||||
set(value) = prefs.edit().putString(KEY_ADMIN_API_KEY, value).apply()
|
||||
get() = accountManager.getActiveAccount()?.apiKey
|
||||
set(_) {
|
||||
// No-op: use AccountManager directly for mutations
|
||||
}
|
||||
|
||||
val isConfigured: Boolean
|
||||
get() = !ghostUrl.isNullOrBlank() && !adminApiKey.isNullOrBlank()
|
||||
get() = accountManager.isConfigured
|
||||
|
||||
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.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
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)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
|
|
@ -17,13 +19,21 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
@Volatile
|
||||
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 {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"swoosh_database"
|
||||
).build()
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2)
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,20 @@ interface LocalPostDao {
|
|||
@Query("SELECT * FROM local_posts ORDER BY updatedAt DESC")
|
||||
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")
|
||||
suspend fun getQueuedPosts(
|
||||
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED)
|
||||
): 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")
|
||||
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)
|
||||
val localId: Long = 0,
|
||||
val ghostId: String? = null,
|
||||
val accountId: String = "",
|
||||
val title: String = "",
|
||||
val content: String = "",
|
||||
val htmlContent: String? = null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package com.swoosh.microblog.data.repository
|
|||
|
||||
import android.content.Context
|
||||
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.GhostApiService
|
||||
import com.swoosh.microblog.data.db.AppDatabase
|
||||
|
|
@ -20,12 +20,17 @@ import java.io.FileOutputStream
|
|||
|
||||
class PostRepository(private val context: Context) {
|
||||
|
||||
private val credentials = CredentialsManager(context)
|
||||
private val accountManager = AccountManager(context)
|
||||
private val dao: LocalPostDao = AppDatabase.getInstance(context).localPostDao()
|
||||
|
||||
fun getActiveAccountId(): String {
|
||||
return accountManager.getActiveAccount()?.id ?: ""
|
||||
}
|
||||
|
||||
private fun getApi(): GhostApiService {
|
||||
val url = credentials.ghostUrl ?: throw IllegalStateException("Ghost URL not configured")
|
||||
return ApiClient.getService(url) { credentials.adminApiKey }
|
||||
val account = accountManager.getActiveAccount()
|
||||
?: throw IllegalStateException("No active account configured")
|
||||
return ApiClient.getService(account.blogUrl) { account.apiKey }
|
||||
}
|
||||
|
||||
// --- Remote operations ---
|
||||
|
|
@ -122,11 +127,25 @@ class PostRepository(private val context: Context) {
|
|||
|
||||
// --- 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 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
package com.swoosh.microblog.ui.feed
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.Settings
|
||||
import androidx.compose.material.icons.filled.WifiOff
|
||||
|
|
@ -19,6 +25,7 @@ import androidx.compose.runtime.*
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
|
@ -27,6 +34,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.swoosh.microblog.data.model.FeedPost
|
||||
import com.swoosh.microblog.data.model.GhostAccount
|
||||
import com.swoosh.microblog.data.model.QueueStatus
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||
|
|
@ -35,11 +43,18 @@ fun FeedScreen(
|
|||
onSettingsClick: () -> Unit,
|
||||
onPostClick: (FeedPost) -> Unit,
|
||||
onCompose: () -> Unit,
|
||||
onAddAccount: () -> Unit = {},
|
||||
viewModel: FeedViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||
val activeAccount by viewModel.activeAccount.collectAsStateWithLifecycle()
|
||||
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
|
||||
val pullRefreshState = rememberPullRefreshState(
|
||||
refreshing = state.isRefreshing,
|
||||
|
|
@ -63,7 +78,51 @@ fun FeedScreen(
|
|||
Scaffold(
|
||||
topBar = {
|
||||
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 = {
|
||||
IconButton(onClick = { viewModel.refresh() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
|
|
@ -86,6 +145,23 @@ fun FeedScreen(
|
|||
.padding(padding)
|
||||
.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.isConnectionError && state.error != null) {
|
||||
// Connection error empty state
|
||||
|
|
@ -166,6 +242,7 @@ fun FeedScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(
|
||||
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
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@ package com.swoosh.microblog.ui.feed
|
|||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
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.repository.PostRepository
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.ConnectException
|
||||
|
|
@ -18,22 +21,37 @@ import javax.net.ssl.SSLException
|
|||
|
||||
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())
|
||||
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 hasMorePages = true
|
||||
private var remotePosts = listOf<FeedPost>()
|
||||
private var localPostsJob: Job? = null
|
||||
|
||||
init {
|
||||
refreshAccountsList()
|
||||
observeLocalPosts()
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refreshAccountsList() {
|
||||
_accounts.value = accountManager.getAccounts()
|
||||
_activeAccount.value = accountManager.getActiveAccount()
|
||||
}
|
||||
|
||||
private fun observeLocalPosts() {
|
||||
viewModelScope.launch {
|
||||
localPostsJob?.cancel()
|
||||
localPostsJob = viewModelScope.launch {
|
||||
repository.getLocalPosts().collect { localPosts ->
|
||||
val queuedPosts = localPosts
|
||||
.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() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isRefreshing = true, error = null, isConnectionError = false) }
|
||||
|
|
@ -179,6 +255,7 @@ data class FeedUiState(
|
|||
val posts: List<FeedPost> = emptyList(),
|
||||
val isRefreshing: Boolean = false,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val isSwitchingAccount: Boolean = false,
|
||||
val error: String? = null,
|
||||
val isConnectionError: Boolean = false
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ object Routes {
|
|||
const val COMPOSER = "composer"
|
||||
const val DETAIL = "detail"
|
||||
const val SETTINGS = "settings"
|
||||
const val ADD_ACCOUNT = "add_account"
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -55,6 +56,9 @@ fun SwooshNavGraph(
|
|||
onCompose = {
|
||||
editPost = null
|
||||
navController.navigate(Routes.COMPOSER)
|
||||
},
|
||||
onAddAccount = {
|
||||
navController.navigate(Routes.ADD_ACCOUNT)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -91,7 +95,11 @@ fun SwooshNavGraph(
|
|||
|
||||
composable(Routes.SETTINGS) {
|
||||
SettingsScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onBack = {
|
||||
feedViewModel.refreshAccountsList()
|
||||
feedViewModel.refresh()
|
||||
navController.popBackStack()
|
||||
},
|
||||
onLogout = {
|
||||
navController.navigate(Routes.SETUP) {
|
||||
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
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
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 com.swoosh.microblog.data.CredentialsManager
|
||||
import com.swoosh.microblog.data.AccountManager
|
||||
import com.swoosh.microblog.data.api.ApiClient
|
||||
import com.swoosh.microblog.ui.feed.AccountAvatar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -21,10 +19,8 @@ fun SettingsScreen(
|
|||
onLogout: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val credentials = remember { CredentialsManager(context) }
|
||||
var url by remember { mutableStateOf(credentials.ghostUrl ?: "") }
|
||||
var apiKey by remember { mutableStateOf(credentials.adminApiKey ?: "") }
|
||||
var saved by remember { mutableStateOf(false) }
|
||||
val accountManager = remember { AccountManager(context) }
|
||||
val activeAccount = remember { accountManager.getActiveAccount() }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
|
@ -44,68 +40,93 @@ fun SettingsScreen(
|
|||
.padding(padding)
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Text("Ghost Instance", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it; saved = false },
|
||||
label = { Text("Ghost URL") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Active account info
|
||||
Text("Current Account", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = apiKey,
|
||||
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
|
||||
},
|
||||
if (activeAccount != null) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Save Changes")
|
||||
}
|
||||
|
||||
if (saved) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
AccountAvatar(account = activeAccount, size = 40)
|
||||
Column {
|
||||
Text(
|
||||
"Settings saved",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
text = activeAccount.name,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = activeAccount.blogUrl
|
||||
.removePrefix("https://")
|
||||
.removePrefix("http://")
|
||||
.removeSuffix("/"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 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))
|
||||
|
||||
// Disconnect current account
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
credentials.clear()
|
||||
activeAccount?.let { account ->
|
||||
accountManager.removeAccount(account.id)
|
||||
ApiClient.reset()
|
||||
val remaining = accountManager.getAccounts()
|
||||
if (remaining.isEmpty()) {
|
||||
onLogout()
|
||||
} else {
|
||||
// There are still accounts, go back to feed with the next one
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
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.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
|
|
@ -38,6 +39,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||
@Composable
|
||||
fun SetupScreen(
|
||||
onSetupComplete: () -> Unit,
|
||||
onBack: (() -> Unit)? = null,
|
||||
viewModel: SetupViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
|
@ -72,7 +74,20 @@ fun SetupScreen(
|
|||
"$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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -100,7 +115,7 @@ fun SetupScreen(
|
|||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "Swoosh",
|
||||
text = if (state.isAddingAccount) "Add Account" else "Swoosh",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
|
@ -108,7 +123,8 @@ fun SetupScreen(
|
|||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
|
@ -123,6 +139,18 @@ fun SetupScreen(
|
|||
.padding(bottom = 32.dp),
|
||||
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(
|
||||
value = state.url,
|
||||
onValueChange = viewModel::updateUrl,
|
||||
|
|
@ -219,7 +247,7 @@ fun SetupScreen(
|
|||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Testing connection...")
|
||||
} else {
|
||||
Text("Connect")
|
||||
Text(if (state.isAddingAccount) "Add Account" else "Connect")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -288,7 +316,7 @@ private fun PulsingCirclesBackground(
|
|||
val w = size.width
|
||||
val h = size.height
|
||||
|
||||
// Circle 1 — primary, upper-left
|
||||
// Circle 1 -- primary, upper-left
|
||||
val radius1 = w * 0.50f * scale1
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
|
|
@ -300,7 +328,7 @@ private fun PulsingCirclesBackground(
|
|||
center = Offset(w * 0.3f, h * 0.35f)
|
||||
)
|
||||
|
||||
// Circle 2 — tertiary, right
|
||||
// Circle 2 -- tertiary, right
|
||||
val radius2 = w * 0.55f * scale2
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
|
|
@ -312,7 +340,7 @@ private fun PulsingCirclesBackground(
|
|||
center = Offset(w * 0.75f, h * 0.45f)
|
||||
)
|
||||
|
||||
// Circle 3 — secondary, bottom-center
|
||||
// Circle 3 -- secondary, bottom-center
|
||||
val radius3 = w * 0.45f * scale3
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package com.swoosh.microblog.ui.setup
|
|||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
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.GhostJwtGenerator
|
||||
import com.swoosh.microblog.data.repository.PostRepository
|
||||
|
|
@ -15,11 +15,10 @@ import kotlinx.coroutines.launch
|
|||
|
||||
class SetupViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val credentials = CredentialsManager(application)
|
||||
private val accountManager = AccountManager(application)
|
||||
|
||||
private val _uiState = MutableStateFlow(SetupUiState(
|
||||
url = credentials.ghostUrl ?: "",
|
||||
apiKey = credentials.adminApiKey ?: ""
|
||||
isAddingAccount = accountManager.hasAnyAccount
|
||||
))
|
||||
val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow()
|
||||
|
||||
|
|
@ -31,6 +30,10 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
|
|||
_uiState.update { it.copy(apiKey = key) }
|
||||
}
|
||||
|
||||
fun updateAccountName(name: String) {
|
||||
_uiState.update { it.copy(accountName = name) }
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val state = _uiState.value
|
||||
if (state.url.isBlank() || state.apiKey.isBlank()) {
|
||||
|
|
@ -48,22 +51,39 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
|
|||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isTesting = true, error = null) }
|
||||
|
||||
// Test connection
|
||||
try {
|
||||
// Validate the API key can generate a JWT
|
||||
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()
|
||||
|
||||
// Test connection
|
||||
val repo = PostRepository(getApplication())
|
||||
repo.fetchPosts(page = 1, limit = 1).fold(
|
||||
onSuccess = {
|
||||
_uiState.update { it.copy(isTesting = false, isSuccess = true) }
|
||||
},
|
||||
onFailure = { e ->
|
||||
// Remove the account since connection failed
|
||||
accountManager.removeAccount(account.id)
|
||||
_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) {
|
||||
_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(
|
||||
val url: String = "",
|
||||
val apiKey: String = "",
|
||||
val accountName: String = "",
|
||||
val isTesting: Boolean = false,
|
||||
val isSuccess: Boolean = false,
|
||||
val isAddingAccount: Boolean = false,
|
||||
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