merge: integrate multi-account support (resolve conflicts)

This commit is contained in:
Paweł Orzech 2026-03-19 11:28:07 +01:00
commit b976ceb9df
No known key found for this signature in database
17 changed files with 1576 additions and 120 deletions

View file

@ -8,7 +8,7 @@ import androidx.activity.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
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
@ -22,8 +22,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 {
val themeMode by themeViewModel.themeMode.collectAsStateWithLifecycle() val themeMode by themeViewModel.themeMode.collectAsStateWithLifecycle()

View 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"
}
}
}

View file

@ -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"
} }
} }

View file

@ -26,6 +26,7 @@ abstract class AppDatabase : RoomDatabase() {
db.execSQL("ALTER TABLE local_posts ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'") db.execSQL("ALTER TABLE local_posts ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'")
db.execSQL("ALTER TABLE local_posts ADD COLUMN imageUris TEXT DEFAULT NULL") db.execSQL("ALTER TABLE local_posts ADD COLUMN imageUris TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE local_posts ADD COLUMN uploadedImageUrls TEXT DEFAULT NULL") db.execSQL("ALTER TABLE local_posts ADD COLUMN uploadedImageUrls TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE local_posts ADD COLUMN accountId TEXT NOT NULL DEFAULT ''")
} }
} }

View file

@ -21,11 +21,20 @@ interface LocalPostDao {
@Query("SELECT * FROM local_posts WHERE status = :status ORDER BY createdAt ASC") @Query("SELECT * FROM local_posts WHERE status = :status ORDER BY createdAt ASC")
fun getPostsByStatusOldestFirst(status: PostStatus): Flow<List<LocalPost>> fun getPostsByStatusOldestFirst(status: PostStatus): 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?

View file

@ -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]
}
}
}

View file

@ -68,6 +68,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,

View file

@ -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
@ -22,12 +22,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 ---
@ -234,7 +239,14 @@ 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()
}
}
fun getLocalPosts(filter: PostFilter, sortOrder: SortOrder): Flow<List<LocalPost>> { fun getLocalPosts(filter: PostFilter, sortOrder: SortOrder): Flow<List<LocalPost>> {
val status = filter.toPostStatus() val status = filter.toPostStatus()
@ -248,7 +260,14 @@ class PostRepository(private val context: Context) {
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)

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -65,6 +66,7 @@ import coil.compose.AsyncImage
import com.swoosh.microblog.data.CredentialsManager import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.ShareUtils import com.swoosh.microblog.data.ShareUtils
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.PostFilter import com.swoosh.microblog.data.model.PostFilter
import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.data.model.QueueStatus
@ -79,6 +81,7 @@ fun FeedScreen(
onPostClick: (FeedPost) -> Unit, onPostClick: (FeedPost) -> Unit,
onCompose: () -> Unit, onCompose: () -> Unit,
onEditPost: (FeedPost) -> Unit, onEditPost: (FeedPost) -> Unit,
onAddAccount: () -> Unit = {},
viewModel: FeedViewModel = viewModel(), viewModel: FeedViewModel = viewModel(),
themeViewModel: ThemeViewModel? = null themeViewModel: ThemeViewModel? = null
) { ) {
@ -92,6 +95,8 @@ fun FeedScreen(
val isSearching by viewModel.isSearching.collectAsStateWithLifecycle() val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
val searchResultCount by viewModel.searchResultCount.collectAsStateWithLifecycle() val searchResultCount by viewModel.searchResultCount.collectAsStateWithLifecycle()
val recentSearches by viewModel.recentSearches.collectAsStateWithLifecycle() val recentSearches by viewModel.recentSearches.collectAsStateWithLifecycle()
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val activeAccount by viewModel.activeAccount.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@ -107,6 +112,10 @@ fun FeedScreen(
val pinnedPosts = displayPosts.filter { it.featured } val pinnedPosts = displayPosts.filter { it.featured }
val regularPosts = displayPosts.filter { !it.featured } val regularPosts = displayPosts.filter { !it.featured }
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,
@ -156,13 +165,53 @@ fun FeedScreen(
} else { } else {
TopAppBar( TopAppBar(
title = { title = {
Column { Row(
Text("Swoosh") verticalAlignment = Alignment.CenterVertically,
if (activeFilter != PostFilter.ALL) { 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(
text = activeFilter.displayName, text = activeAccount?.name ?: "Swoosh",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary 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 (activeFilter != PostFilter.ALL) {
Text(
text = activeFilter.displayName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
if (accounts.size > 1 || accounts.isNotEmpty()) {
Icon(
Icons.Default.KeyboardArrowDown,
contentDescription = "Switch account",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
@ -235,8 +284,24 @@ fun FeedScreen(
) )
} }
// Show recent searches when search is active but query is empty // Loading overlay during account switch
if (isSearchActive && searchQuery.isBlank() && recentSearches.isNotEmpty()) { 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 (isSearchActive && searchQuery.isBlank() && recentSearches.isNotEmpty()) {
// Show recent searches when search is active but query is empty
RecentSearchesList( RecentSearchesList(
recentSearches = recentSearches, recentSearches = recentSearches,
onSearchTap = viewModel::onRecentSearchTap, onSearchTap = viewModel::onRecentSearchTap,
@ -550,6 +615,86 @@ 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 }
)
}
// Account 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") }
}
)
}
// Account 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) @OptIn(ExperimentalMaterial3Api::class)
@ -804,6 +949,175 @@ fun SwipeBackground(dismissState: SwipeToDismissBoxState) {
} }
} }
@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
)
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun SearchTopBar( fun SearchTopBar(

View file

@ -5,9 +5,11 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
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.CredentialsManager import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.FeedPreferences import com.swoosh.microblog.data.FeedPreferences
import com.swoosh.microblog.data.HashtagParser import com.swoosh.microblog.data.HashtagParser
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.db.Converters import com.swoosh.microblog.data.db.Converters
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
@ -34,7 +36,8 @@ data class SnackbarEvent(
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 feedPreferences = FeedPreferences(application) private val feedPreferences = FeedPreferences(application)
private val searchHistoryManager = SearchHistoryManager(application) private val searchHistoryManager = SearchHistoryManager(application)
@ -68,6 +71,12 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val _recentSearches = MutableStateFlow<List<String>>(emptyList()) private val _recentSearches = MutableStateFlow<List<String>>(emptyList())
val recentSearches: StateFlow<List<String>> = _recentSearches.asStateFlow() val recentSearches: StateFlow<List<String>> = _recentSearches.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>()
@ -75,6 +84,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private var searchJob: Job? = null private var searchJob: Job? = null
init { init {
refreshAccountsList()
observeLocalPosts() observeLocalPosts()
observeSearchQuery() observeSearchQuery()
if (CredentialsManager(getApplication()).isConfigured) { if (CredentialsManager(getApplication()).isConfigured) {
@ -101,6 +111,11 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
fun refreshAccountsList() {
_accounts.value = accountManager.getAccounts()
_activeAccount.value = accountManager.getActiveAccount()
}
private fun observeLocalPosts() { private fun observeLocalPosts() {
localPostsJob?.cancel() localPostsJob?.cancel()
localPostsJob = viewModelScope.launch { localPostsJob = viewModelScope.launch {
@ -201,6 +216,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) }
@ -543,6 +616,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,
val snackbarMessage: String? = null, val snackbarMessage: String? = null,

View file

@ -25,6 +25,7 @@ object Routes {
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val STATS = "stats" const val STATS = "stats"
const val PREVIEW = "preview" const val PREVIEW = "preview"
const val ADD_ACCOUNT = "add_account"
} }
@Composable @Composable
@ -68,6 +69,9 @@ fun SwooshNavGraph(
onEditPost = { post -> onEditPost = { post ->
editPost = post editPost = post
navController.navigate(Routes.COMPOSER) navController.navigate(Routes.COMPOSER)
},
onAddAccount = {
navController.navigate(Routes.ADD_ACCOUNT)
} }
) )
} }
@ -118,7 +122,11 @@ fun SwooshNavGraph(
composable(Routes.SETTINGS) { composable(Routes.SETTINGS) {
SettingsScreen( SettingsScreen(
themeViewModel = themeViewModel, themeViewModel = themeViewModel,
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 }
@ -142,5 +150,18 @@ fun SwooshNavGraph(
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() }
) )
} }
composable(Routes.ADD_ACCOUNT) {
SetupScreen(
onSetupComplete = {
feedViewModel.refreshAccountsList()
feedViewModel.refresh()
navController.popBackStack()
},
onBack = {
navController.popBackStack()
}
)
}
} }
} }

View file

@ -2,7 +2,6 @@ package com.swoosh.microblog.ui.settings
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
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
@ -15,12 +14,11 @@ 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.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 androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
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
import com.swoosh.microblog.ui.theme.ThemeMode import com.swoosh.microblog.ui.theme.ThemeMode
import com.swoosh.microblog.ui.theme.ThemeViewModel import com.swoosh.microblog.ui.theme.ThemeViewModel
@ -33,10 +31,8 @@ fun SettingsScreen(
onStatsClick: () -> Unit = {} onStatsClick: () -> 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) }
val currentThemeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle() val currentThemeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
@ -74,54 +70,50 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
} }
// --- Ghost Instance section --- // --- Current Account section ---
Text("Ghost Instance", style = MaterialTheme.typography.titleMedium) Text("Current Account", 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()
)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField( if (activeAccount != null) {
value = apiKey, Card(
onValueChange = { apiKey = it; saved = false }, modifier = Modifier.fillMaxWidth()
label = { Text("Admin API Key") }, ) {
singleLine = true, Row(
visualTransformation = PasswordVisualTransformation(), modifier = Modifier
modifier = Modifier.fillMaxWidth() .fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
AccountAvatar(account = activeAccount, size = 40)
Column {
Text(
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(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(16.dp)) Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
credentials.ghostUrl = url
credentials.adminApiKey = apiKey
ApiClient.reset()
saved = true
},
modifier = Modifier.fillMaxWidth()
) {
Text("Save Changes")
}
if (saved) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"Settings saved",
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(32.dp))
HorizontalDivider() HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@ -143,18 +135,46 @@ fun SettingsScreen(
HorizontalDivider() 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 ->
ApiClient.reset() accountManager.removeAccount(account.id)
onLogout() 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(), 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")
}
} }
} }
} }

View file

@ -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(

View file

@ -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,20 +51,37 @@ 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
ApiClient.reset()
val repo = PostRepository(getApplication()) // Add account
repo.fetchPosts(page = 1, limit = 1).fold( val result = accountManager.addAccount(
onSuccess = { name = state.accountName,
_uiState.update { it.copy(isTesting = false, isSuccess = true) } 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 -> onFailure = { e ->
_uiState.update { it.copy(isTesting = false, error = "Connection failed: ${e.message}") } _uiState.update { it.copy(isTesting = false, error = e.message) }
} }
) )
} catch (e: Exception) { } catch (e: Exception) {
@ -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
) )

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}