diff --git a/app/src/main/java/com/swoosh/microblog/MainActivity.kt b/app/src/main/java/com/swoosh/microblog/MainActivity.kt index da32a06..9980d23 100644 --- a/app/src/main/java/com/swoosh/microblog/MainActivity.kt +++ b/app/src/main/java/com/swoosh/microblog/MainActivity.kt @@ -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 { diff --git a/app/src/main/java/com/swoosh/microblog/data/AccountManager.kt b/app/src/main/java/com/swoosh/microblog/data/AccountManager.kt new file mode 100644 index 0000000..1facf0a --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/AccountManager.kt @@ -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 { + val json = prefs.getString(KEY_ACCOUNTS, null) ?: return emptyList() + val type = object : TypeToken>() {}.type + return try { + val accounts: List = 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 { + 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) { + // 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): 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" + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/CredentialsManager.kt b/app/src/main/java/com/swoosh/microblog/data/CredentialsManager.kt index 334fd64..df8c7cf 100644 --- a/app/src/main/java/com/swoosh/microblog/data/CredentialsManager.kt +++ b/app/src/main/java/com/swoosh/microblog/data/CredentialsManager.kt @@ -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() - } - - companion object { - private const val KEY_GHOST_URL = "ghost_url" - private const val KEY_ADMIN_API_KEY = "admin_api_key" + val activeAccount = accountManager.getActiveAccount() + if (activeAccount != null) { + accountManager.removeAccount(activeAccount.id) + } } } diff --git a/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt b/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt index 3b3f23f..aedcd43 100644 --- a/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt +++ b/app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt @@ -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 } diff --git a/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt index 02a5651..0a6e4f2 100644 --- a/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt +++ b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt @@ -11,11 +11,20 @@ interface LocalPostDao { @Query("SELECT * FROM local_posts ORDER BY updatedAt DESC") fun getAllPosts(): Flow> + @Query("SELECT * FROM local_posts WHERE accountId = :accountId ORDER BY updatedAt DESC") + fun getPostsByAccount(accountId: String): Flow> + @Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC") suspend fun getQueuedPosts( statuses: List = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED) ): List + @Query("SELECT * FROM local_posts WHERE accountId = :accountId AND queueStatus IN (:statuses) ORDER BY createdAt ASC") + suspend fun getQueuedPostsByAccount( + accountId: String, + statuses: List = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED) + ): List + @Query("SELECT * FROM local_posts WHERE localId = :localId") suspend fun getPostById(localId: Long): LocalPost? diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostAccount.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostAccount.kt new file mode 100644 index 0000000..9b76069 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostAccount.kt @@ -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] + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt index 9adfc02..2d27ccb 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt @@ -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, diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt index 8a567ab..c65b943 100644 --- a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt +++ b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt @@ -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> = dao.getAllPosts() + fun getLocalPosts(): Flow> { + val accountId = getActiveAccountId() + return if (accountId.isNotEmpty()) { + dao.getPostsByAccount(accountId) + } else { + dao.getAllPosts() + } + } suspend fun getQueuedPosts(): List = 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) diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index 9952c7c..cf2fc91 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -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(null) } + var showRenameDialog by remember { mutableStateOf(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,82 +145,100 @@ fun FeedScreen( .padding(padding) .pullRefresh(pullRefreshState) ) { - if (state.posts.isEmpty() && !state.isRefreshing) { - if (state.isConnectionError && state.error != null) { - // Connection error empty state - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 32.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.WifiOff, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.error - ) + // 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 = state.error!!, + text = "Switching account...", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(16.dp)) - FilledTonalButton(onClick = { viewModel.refresh() }) { + } + } + } else { + if (state.posts.isEmpty() && !state.isRefreshing) { + if (state.isConnectionError && state.error != null) { + // Connection error empty state + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { Icon( - Icons.Default.Refresh, + imageVector = Icons.Default.WifiOff, contentDescription = null, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = state.error!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + FilledTonalButton(onClick = { viewModel.refresh() }) { + Icon( + Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Retry") + } + } + } else { + // Normal empty state + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "No posts yet", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Tap + to write your first post", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Retry") } } - } else { - // Normal empty state - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "No posts yet", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Tap + to write your first post", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> + PostCard( + post = post, + onClick = { onPostClick(post) }, + onCancelQueue = { viewModel.cancelQueuedPost(post) } ) } - } - } - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 8.dp) - ) { - items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> - PostCard( - post = post, - onClick = { onPostClick(post) }, - onCancelQueue = { viewModel.cancelQueuedPost(post) } - ) - } - - if (state.isLoadingMore) { - item { - Box( - modifier = Modifier.fillMaxWidth().padding(16.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) + if (state.isLoadingMore) { + item { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } } } } @@ -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, + 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 diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index 350de2c..c1ead35 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -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 = _uiState.asStateFlow() + private val _accounts = MutableStateFlow>(emptyList()) + val accounts: StateFlow> = _accounts.asStateFlow() + + private val _activeAccount = MutableStateFlow(null) + val activeAccount: StateFlow = _activeAccount.asStateFlow() + private var currentPage = 1 private var hasMorePages = true private var remotePosts = listOf() + 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 = emptyList(), val isRefreshing: Boolean = false, val isLoadingMore: Boolean = false, + val isSwitchingAccount: Boolean = false, val error: String? = null, val isConnectionError: Boolean = false ) diff --git a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt index a33cc63..16f0912 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt @@ -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() + } + ) + } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt index dac1611..0c7f325 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt @@ -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() + if (activeAccount != null) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .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(24.dp)) + HorizontalDivider() Spacer(modifier = Modifier.height(16.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)) - Divider() - Spacer(modifier = Modifier.height(16.dp)) - + // Disconnect current account OutlinedButton( onClick = { - credentials.clear() - ApiClient.reset() - onLogout() + 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") + } } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt index eb216d9..941379d 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt @@ -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( diff --git a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt index 795e658..485afab 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt @@ -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 = _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,20 +51,37 @@ 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 - ApiClient.reset() - val repo = PostRepository(getApplication()) - repo.fetchPosts(page = 1, limit = 1).fold( - onSuccess = { - _uiState.update { it.copy(isTesting = false, isSuccess = true) } + // 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 = "Connection failed: ${e.message}") } + _uiState.update { it.copy(isTesting = false, error = e.message) } } ) } catch (e: Exception) { @@ -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 ) diff --git a/app/src/test/java/com/swoosh/microblog/data/AccountManagerTest.kt b/app/src/test/java/com/swoosh/microblog/data/AccountManagerTest.kt new file mode 100644 index 0000000..c2f4a80 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/AccountManagerTest.kt @@ -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(), 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() + 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) + } +} diff --git a/app/src/test/java/com/swoosh/microblog/data/db/LocalPostDaoTest.kt b/app/src/test/java/com/swoosh/microblog/data/db/LocalPostDaoTest.kt new file mode 100644 index 0000000..b006d1e --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/db/LocalPostDaoTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/swoosh/microblog/data/model/GhostAccountTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/GhostAccountTest.kt new file mode 100644 index 0000000..6882483 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/model/GhostAccountTest.kt @@ -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) + } +}