diff --git a/AGENTS.md b/AGENTS.md index d664b74..98f7c7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,8 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel - Compile/target SDK: API 35. - Baseline tests: - JVM unit tests for auth URL normalization and auth error mapping in `app/src/test/`. - - Instrumentation login flow tests in `app/src/androidTest/`. + - JVM unit tests for boards repository and boards view model in `app/src/test/space/hackenslacker/kanbn4droid/app/boards/`. + - Instrumentation tests for login and boards flows in `app/src/androidTest/`. ## Command-line workflow @@ -69,6 +70,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel - Long-pressing an existing board shows a modal dialog asking the user if they want to delete the board, with buttons for "Cancel" and "Delete". - Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure". - Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API. +- Current status: implemented in `BoardsActivity` using XML Views, `RecyclerView`, `SwipeRefreshLayout`, and a `BoardsViewModel`/`BoardsRepository` flow. The screen includes auto-refresh on entry, pull-to-refresh, create board dialog with optional template selector, and two-step board delete confirmation. Board taps navigate to `BoardDetailPlaceholderActivity` while full board detail is still pending. **Board detail view** diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ded7adb..7794d5a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,10 +50,17 @@ dependencies { implementation(libs.androidx.credentials.play.services.auth) implementation(libs.material) implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.recyclerview) + implementation(libs.androidx.swiperefreshlayout) implementation(libs.kotlinx.coroutines.android) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.espresso.contrib) androidTestImplementation(libs.androidx.espresso.intents) } diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt new file mode 100644 index 0000000..daeaa62 --- /dev/null +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt @@ -0,0 +1,181 @@ +package space.hackenslacker.kanbn4droid.app + +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.longClick +import androidx.test.espresso.action.ViewActions.replaceText +import androidx.test.espresso.action.ViewActions.swipeDown +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore +import space.hackenslacker.kanbn4droid.app.auth.AuthResult +import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient +import space.hackenslacker.kanbn4droid.app.auth.SessionStore +import space.hackenslacker.kanbn4droid.app.boards.BoardSummary +import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate +import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult + +@RunWith(AndroidJUnit4::class) +class BoardsFlowTest { + @Before + fun setUp() { + MainActivity.dependencies.clear() + Intents.init() + MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/") } + MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore("api") } + } + + @After + fun tearDown() { + Intents.release() + MainActivity.dependencies.clear() + } + + @Test + fun boardTapNavigatesToDetailPlaceholderWithExtras() { + MainActivity.dependencies.apiClientFactory = { + FakeBoardsApiClient( + boards = mutableListOf(BoardSummary("1", "Alpha")), + templates = listOf(BoardTemplate("tpl-1", "Starter")), + ) + } + + ActivityScenario.launch(BoardsActivity::class.java) + + onView(withText("Alpha")).perform(click()) + + Intents.intended(hasComponent(BoardDetailPlaceholderActivity::class.java.name)) + Intents.intended(hasExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_ID, "1")) + Intents.intended(hasExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_TITLE, "Alpha")) + } + + @Test + fun createBoardWithTemplateNavigatesToCreatedBoard() { + MainActivity.dependencies.apiClientFactory = { + FakeBoardsApiClient( + boards = mutableListOf(BoardSummary("1", "Alpha")), + templates = listOf(BoardTemplate("tpl-1", "Starter")), + ) + } + + ActivityScenario.launch(BoardsActivity::class.java) + + onView(withId(R.id.createBoardFab)).perform(click()) + onView(withId(R.id.createBoardNameInput)).perform(replaceText("Roadmap")) + onView(withId(R.id.useTemplateChip)).perform(click()) + onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click()) + + Intents.intended(hasComponent(BoardDetailPlaceholderActivity::class.java.name)) + Intents.intended(hasExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_TITLE, "Roadmap")) + } + + @Test + fun deleteBoardRequiresSecondConfirmation() { + val fake = FakeBoardsApiClient( + boards = mutableListOf(BoardSummary("1", "Alpha"), BoardSummary("2", "Beta")), + templates = emptyList(), + ) + MainActivity.dependencies.apiClientFactory = { fake } + + ActivityScenario.launch(BoardsActivity::class.java) + + onView(withText("Alpha")).perform(longClick()) + onView(withText(R.string.delete)).inRoot(isDialog()).perform(click()) + onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click()) + + onView(withText("Alpha")).check(doesNotExist()) + onView(withText("Beta")).check(matches(isDisplayed())) + } + + @Test + fun pullToRefreshWorks() { + MainActivity.dependencies.apiClientFactory = { + FakeBoardsApiClient( + boards = mutableListOf(BoardSummary("1", "Alpha")), + templates = emptyList(), + ) + } + + ActivityScenario.launch(BoardsActivity::class.java) + + onView(withId(R.id.boardsSwipeRefresh)).perform(swipeDown()) + onView(withText("Alpha")).check(matches(isDisplayed())) + } + + private class InMemorySessionStore( + private var baseUrl: String? = null, + ) : SessionStore { + override fun getBaseUrl(): String? = baseUrl + + override fun saveBaseUrl(url: String) { + baseUrl = url + } + + override fun clearBaseUrl() { + baseUrl = null + } + } + + private class InMemoryApiKeyStore( + private var key: String?, + ) : ApiKeyStore { + override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result { + key = apiKey + return Result.success(Unit) + } + + override suspend fun getApiKey(baseUrl: String): Result = Result.success(key) + + override suspend fun invalidateApiKey(baseUrl: String): Result { + key = null + return Result.success(Unit) + } + } + + private class FakeBoardsApiClient( + private val boards: MutableList, + private val templates: List, + ) : KanbnApiClient { + override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success + + override suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult> { + return BoardsApiResult.Success(boards.toList()) + } + + override suspend fun listBoardTemplates( + baseUrl: String, + apiKey: String, + ): BoardsApiResult> { + return BoardsApiResult.Success(templates) + } + + override suspend fun createBoard( + baseUrl: String, + apiKey: String, + name: String, + templateId: String?, + ): BoardsApiResult { + val next = BoardSummary((boards.size + 1).toString(), name) + boards.add(next) + return BoardsApiResult.Success(next) + } + + override suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + boards.removeAll { it.id == boardId } + return BoardsApiResult.Success(Unit) + } + } +} diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt index 39dd69c..a8c22c3 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt @@ -59,7 +59,7 @@ class LoginFlowTest { ActivityScenario.launch(MainActivity::class.java) - Intents.intended(hasComponent(BoardsPlaceholderActivity::class.java.name)) + Intents.intended(hasComponent(BoardsActivity::class.java.name)) } @Test @@ -91,7 +91,7 @@ class LoginFlowTest { onView(withId(R.id.apiKeyInput)).perform(replaceText("kan_new"), closeSoftKeyboard()) onView(withId(R.id.signInButton)).perform(click()) - Intents.intended(hasComponent(BoardsPlaceholderActivity::class.java.name)) + Intents.intended(hasComponent(BoardsActivity::class.java.name)) assertEquals("https://kan.bn/", sessionStore.getBaseUrl()) assertEquals("kan_new", keyStore.savedKey) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 986efec..110764f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,7 +12,10 @@ android:usesCleartextTraffic="true" android:theme="@style/Theme.Kanbn4Droid"> + navigateToBoard(board) }, + onBoardLongClick = { board -> showDeleteConfirmation(board) }, + ) + recyclerView.layoutManager = LinearLayoutManager(this) + recyclerView.adapter = boardsAdapter + } + + private fun setupInteractions() { + swipeRefresh.setOnRefreshListener { + viewModel.refreshBoards() + } + + createFab.setOnClickListener { + showCreateBoardDialog() + } + } + + private fun observeViewModel() { + lifecycleScope.launch { + viewModel.uiState.collect { render(it) } + } + + lifecycleScope.launch { + viewModel.events.collect { event -> + when (event) { + is BoardsUiEvent.NavigateToBoard -> { + navigateToBoard(BoardSummary(event.boardId, event.boardTitle)) + } + + is BoardsUiEvent.ShowServerError -> { + MaterialAlertDialogBuilder(this@BoardsActivity) + .setMessage(event.message) + .setPositiveButton(R.string.ok, null) + .show() + } + } + } + } + } + + private fun render(state: BoardsUiState) { + boardsAdapter.submitBoards(state.boards) + swipeRefresh.isRefreshing = state.isRefreshing + initialProgress.visibility = if (state.isInitialLoading) View.VISIBLE else View.GONE + emptyStateText.visibility = if (!state.isInitialLoading && state.boards.isEmpty()) View.VISIBLE else View.GONE + createFab.isEnabled = !state.isMutating + } + + private fun showCreateBoardDialog() { + val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_create_board, null) + val nameLayout: TextInputLayout = dialogView.findViewById(R.id.createBoardNameLayout) + val nameInput: TextInputEditText = dialogView.findViewById(R.id.createBoardNameInput) + val useTemplateChip: Chip = dialogView.findViewById(R.id.useTemplateChip) + val templateLayout: TextInputLayout = dialogView.findViewById(R.id.templateSelectorLayout) + val templateInput: AutoCompleteTextView = dialogView.findViewById(R.id.templateSelectorInput) + + var selectedTemplateId: String? = null + var templateCollectorJob: Job? = null + + fun bindTemplates() { + val templates = viewModel.uiState.value.templates + templateInput.setAdapter( + ArrayAdapter( + this, + android.R.layout.simple_dropdown_item_1line, + templates.map { it.name }, + ), + ) + if (useTemplateChip.isChecked && selectedTemplateId == null && templates.isNotEmpty()) { + val firstTemplate = templates.first() + selectedTemplateId = firstTemplate.id + templateInput.setText(firstTemplate.name, false) + } + } + + val dialog = MaterialAlertDialogBuilder(this) + .setTitle(R.string.create_board) + .setView(dialogView) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.create_board, null) + .create() + + useTemplateChip.setOnCheckedChangeListener { _, isChecked -> + templateLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + if (isChecked) { + viewModel.loadTemplatesIfNeeded() + bindTemplates() + } else { + selectedTemplateId = null + templateInput.setText("", false) + templateLayout.error = null + } + } + + templateInput.setOnItemClickListener { _, _, position, _ -> + val templates = viewModel.uiState.value.templates + selectedTemplateId = templates.getOrNull(position)?.id + templateLayout.error = null + } + + dialog.setOnShowListener { + templateCollectorJob = lifecycleScope.launch { + viewModel.uiState.collect { + if (dialog.isShowing && useTemplateChip.isChecked) { + bindTemplates() + } + } + } + + val positiveButton: Button = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + positiveButton.setOnClickListener { + nameLayout.error = null + templateLayout.error = null + + val boardName = nameInput.text?.toString().orEmpty().trim() + if (boardName.isBlank()) { + nameLayout.error = getString(R.string.board_name_required) + return@setOnClickListener + } + + if (useTemplateChip.isChecked && selectedTemplateId.isNullOrBlank()) { + templateLayout.error = getString(R.string.template_required) + return@setOnClickListener + } + + viewModel.createBoard(boardName, selectedTemplateId) + dialog.dismiss() + } + } + + dialog.setOnDismissListener { + templateCollectorJob?.cancel() + } + + dialog.show() + } + + private fun showDeleteConfirmation(board: BoardSummary) { + MaterialAlertDialogBuilder(this) + .setMessage(getString(R.string.delete_board_confirmation, board.title)) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.delete) { _, _ -> + MaterialAlertDialogBuilder(this) + .setMessage(getString(R.string.delete_board_second_confirmation, board.title)) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.im_sure) { _, _ -> + viewModel.deleteBoard(board) + } + .show() + } + .show() + } + + private fun navigateToBoard(board: BoardSummary) { + startActivity( + Intent(this, BoardDetailPlaceholderActivity::class.java) + .putExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_ID, board.id) + .putExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_TITLE, board.title), + ) + } + + protected fun provideSessionStore(): SessionStore { + return MainActivity.dependencies.sessionStoreFactory?.invoke(this) ?: SessionPreferences(applicationContext) + } + + protected fun provideApiKeyStore(): ApiKeyStore { + return MainActivity.dependencies.apiKeyStoreFactory?.invoke(this) + ?: CredentialManagerApiKeyStore(this) + } + + protected fun provideApiClient(): KanbnApiClient { + return MainActivity.dependencies.apiClientFactory?.invoke() ?: HttpKanbnApiClient() + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsPlaceholderActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsPlaceholderActivity.kt deleted file mode 100644 index 45abfff..0000000 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsPlaceholderActivity.kt +++ /dev/null @@ -1,11 +0,0 @@ -package space.hackenslacker.kanbn4droid.app - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity - -class BoardsPlaceholderActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_boards_placeholder) - } -} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt index 36b6f92..1eaf99c 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt @@ -102,7 +102,7 @@ class MainActivity : AppCompatActivity() { return when (apiClient.healthCheck(storedBaseUrl, storedApiKey)) { AuthResult.Success -> { - openBoardsPlaceholder() + openBoards() true } @@ -156,7 +156,7 @@ class MainActivity : AppCompatActivity() { } if (saveKeyResult.isSuccess) { sessionStore.saveBaseUrl(normalizedBaseUrl) - openBoardsPlaceholder() + openBoards() } else { loginProgress.visibility = View.GONE statusText.visibility = View.GONE @@ -189,21 +189,21 @@ class MainActivity : AppCompatActivity() { signInButton.isEnabled = enabled } - private fun openBoardsPlaceholder() { - startActivity(Intent(this, BoardsPlaceholderActivity::class.java)) + private fun openBoards() { + startActivity(Intent(this, BoardsActivity::class.java)) finish() } - protected open fun provideSessionStore(): SessionStore { + protected fun provideSessionStore(): SessionStore { return dependencies.sessionStoreFactory?.invoke(this) ?: SessionPreferences(applicationContext) } - protected open fun provideApiKeyStore(): ApiKeyStore { + protected fun provideApiKeyStore(): ApiKeyStore { return dependencies.apiKeyStoreFactory?.invoke(this) ?: CredentialManagerApiKeyStore(this) } - protected open fun provideApiClient(): KanbnApiClient { + protected fun provideApiClient(): KanbnApiClient { return dependencies.apiClientFactory?.invoke() ?: HttpKanbnApiClient() } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt index 18c2c6b..07b9599 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt @@ -3,11 +3,37 @@ package space.hackenslacker.kanbn4droid.app.auth import java.io.IOException import java.net.HttpURLConnection import java.net.URL +import org.json.JSONArray +import org.json.JSONObject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import space.hackenslacker.kanbn4droid.app.boards.BoardSummary +import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate +import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult interface KanbnApiClient { suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult + + suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult> { + return BoardsApiResult.Failure("Boards listing is not implemented.") + } + + suspend fun listBoardTemplates(baseUrl: String, apiKey: String): BoardsApiResult> { + return BoardsApiResult.Failure("Board templates listing is not implemented.") + } + + suspend fun createBoard( + baseUrl: String, + apiKey: String, + name: String, + templateId: String?, + ): BoardsApiResult { + return BoardsApiResult.Failure("Board creation is not implemented.") + } + + suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Board deletion is not implemented.") + } } class HttpKanbnApiClient : KanbnApiClient { @@ -40,4 +66,225 @@ class HttpKanbnApiClient : KanbnApiClient { } } } + + override suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult> { + return withContext(Dispatchers.IO) { + request( + baseUrl = baseUrl, + path = "/api/v1/boards", + method = "GET", + apiKey = apiKey, + ) { code, body -> + if (code in 200..299) { + BoardsApiResult.Success(parseBoards(body)) + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + } + } + + override suspend fun listBoardTemplates( + baseUrl: String, + apiKey: String, + ): BoardsApiResult> { + return withContext(Dispatchers.IO) { + request( + baseUrl = baseUrl, + path = "/api/v1/board-templates", + method = "GET", + apiKey = apiKey, + ) { code, body -> + if (code in 200..299) { + BoardsApiResult.Success(parseTemplates(body)) + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + } + } + + override suspend fun createBoard( + baseUrl: String, + apiKey: String, + name: String, + templateId: String?, + ): BoardsApiResult { + return withContext(Dispatchers.IO) { + val payload = JSONObject().put("title", name) + if (!templateId.isNullOrBlank()) { + payload.put("template_id", templateId) + } + + request( + baseUrl = baseUrl, + path = "/api/v1/boards", + method = "POST", + apiKey = apiKey, + body = payload.toString(), + ) { code, rawBody -> + if (code in 200..299) { + BoardsApiResult.Success(parseSingleBoard(rawBody, fallbackName = name)) + } else { + BoardsApiResult.Failure(serverMessage(rawBody, code)) + } + } + } + } + + override suspend fun deleteBoard( + baseUrl: String, + apiKey: String, + boardId: String, + ): BoardsApiResult { + return withContext(Dispatchers.IO) { + request( + baseUrl = baseUrl, + path = "/api/v1/boards/$boardId", + method = "DELETE", + apiKey = apiKey, + ) { code, body -> + if (code in 200..299) { + BoardsApiResult.Success(Unit) + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + } + } + + private fun request( + baseUrl: String, + path: String, + method: String, + apiKey: String, + body: String? = null, + handler: (code: Int, body: String) -> BoardsApiResult, + ): BoardsApiResult { + val endpoint = "${baseUrl.trimEnd('/')}$path" + val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply { + requestMethod = method + connectTimeout = 10_000 + readTimeout = 10_000 + setRequestProperty("x-api-key", apiKey) + if (body != null) { + doOutput = true + setRequestProperty("Content-Type", "application/json") + } + } + + return try { + if (body != null) { + connection.outputStream.bufferedWriter().use { writer -> writer.write(body) } + } + val code = connection.responseCode + val responseBody = readResponseBody(connection, code) + handler(code, responseBody) + } catch (throwable: Throwable) { + BoardsApiResult.Failure( + AuthErrorMapper.fromException(throwable).message, + ) + } finally { + try { + connection.inputStream?.close() + } catch (_: IOException) { + } + try { + connection.errorStream?.close() + } catch (_: IOException) { + } + connection.disconnect() + } + } + + private fun readResponseBody(connection: HttpURLConnection, code: Int): String { + val stream = if (code in 200..299) connection.inputStream else connection.errorStream + return stream?.bufferedReader()?.use { it.readText() }.orEmpty() + } + + private fun parseBoards(body: String): List { + if (body.isBlank()) { + return emptyList() + } + + val trimmed = body.trim() + return if (trimmed.startsWith("[")) { + parseBoardsArray(JSONArray(trimmed)) + } else { + val root = JSONObject(trimmed) + val candidates = listOf("boards", "items", "data") + val array = candidates.firstNotNullOfOrNull { key -> root.optJSONArray(key) } ?: JSONArray() + parseBoardsArray(array) + } + } + + private fun parseBoardsArray(array: JSONArray): List { + val boards = mutableListOf() + for (index in 0 until array.length()) { + val item = array.optJSONObject(index) ?: continue + val id = item.opt("id")?.toString().orEmpty() + val title = item.optString("title").ifBlank { + item.optString("name").ifBlank { "Board" } + } + if (id.isNotBlank()) { + boards += BoardSummary(id = id, title = title) + } + } + return boards + } + + private fun parseSingleBoard(body: String, fallbackName: String): BoardSummary { + if (body.isBlank()) { + return BoardSummary(id = "new", title = fallbackName) + } + + val root = JSONObject(body) + val id = root.opt("id")?.toString().orEmpty().ifBlank { root.opt("board_id")?.toString().orEmpty() } + val title = root.optString("title").ifBlank { root.optString("name").ifBlank { fallbackName } } + return BoardSummary(id = if (id.isBlank()) "new" else id, title = title) + } + + private fun parseTemplates(body: String): List { + if (body.isBlank()) { + return emptyList() + } + + val trimmed = body.trim() + val array = if (trimmed.startsWith("[")) { + JSONArray(trimmed) + } else { + val root = JSONObject(trimmed) + listOf("templates", "items", "data") + .firstNotNullOfOrNull { root.optJSONArray(it) } + ?: JSONArray() + } + + val templates = mutableListOf() + for (index in 0 until array.length()) { + val item = array.optJSONObject(index) ?: continue + val id = item.opt("id")?.toString().orEmpty() + val name = item.optString("name").ifBlank { + item.optString("title").ifBlank { "Template" } + } + if (id.isNotBlank()) { + templates += BoardTemplate(id = id, name = name) + } + } + return templates + } + + private fun serverMessage(body: String, code: Int): String { + if (body.isBlank()) { + return "Server error: $code" + } + + return runCatching { + val root = JSONObject(body) + listOf("message", "error", "cause", "detail") + .firstNotNullOfOrNull { key -> root.optString(key).takeIf { it.isNotBlank() } } + ?: "Server error: $code" + }.getOrElse { + "Server error: $code" + } + } } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsAdapter.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsAdapter.kt new file mode 100644 index 0000000..7e3ea0f --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsAdapter.kt @@ -0,0 +1,55 @@ +package space.hackenslacker.kanbn4droid.app.boards + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import space.hackenslacker.kanbn4droid.app.R + +class BoardsAdapter( + private val onBoardClick: (BoardSummary) -> Unit, + private val onBoardLongClick: (BoardSummary) -> Unit, +) : RecyclerView.Adapter() { + private val boards = mutableListOf() + + fun submitBoards(items: List) { + boards.clear() + boards.addAll(items) + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BoardViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_board_card, parent, false) + return BoardViewHolder(view) + } + + override fun onBindViewHolder(holder: BoardViewHolder, position: Int) { + val board = boards[position] + holder.bind( + board = board, + onClick = onBoardClick, + onLongClick = onBoardLongClick, + ) + } + + override fun getItemCount(): Int = boards.size + + class BoardViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val titleText: TextView = itemView.findViewById(R.id.boardTitleText) + + fun bind( + board: BoardSummary, + onClick: (BoardSummary) -> Unit, + onLongClick: (BoardSummary) -> Unit, + ) { + titleText.text = board.title + itemView.setOnClickListener { onClick(board) } + itemView.setOnLongClickListener { + onLongClick(board) + true + } + } + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsModels.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsModels.kt new file mode 100644 index 0000000..971b068 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsModels.kt @@ -0,0 +1,16 @@ +package space.hackenslacker.kanbn4droid.app.boards + +data class BoardSummary( + val id: String, + val title: String, +) + +data class BoardTemplate( + val id: String, + val name: String, +) + +sealed interface BoardsApiResult { + data class Success(val value: T) : BoardsApiResult + data class Failure(val message: String) : BoardsApiResult +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt new file mode 100644 index 0000000..0e92d84 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt @@ -0,0 +1,60 @@ +package space.hackenslacker.kanbn4droid.app.boards + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore +import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient +import space.hackenslacker.kanbn4droid.app.auth.SessionStore + +class BoardsRepository( + private val sessionStore: SessionStore, + private val apiKeyStore: ApiKeyStore, + private val apiClient: KanbnApiClient, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + suspend fun listBoards(): BoardsApiResult> { + val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.") + return apiClient.listBoards(session.baseUrl, session.apiKey) + } + + suspend fun listTemplates(): BoardsApiResult> { + val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.") + return apiClient.listBoardTemplates(session.baseUrl, session.apiKey) + } + + suspend fun createBoard(name: String, templateId: String?): BoardsApiResult { + val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.") + if (name.isBlank()) { + return BoardsApiResult.Failure("Board name is required") + } + return apiClient.createBoard( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + name = name.trim(), + templateId = templateId, + ) + } + + suspend fun deleteBoard(boardId: String): BoardsApiResult { + val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.") + if (boardId.isBlank()) { + return BoardsApiResult.Failure("Board id is required") + } + return apiClient.deleteBoard(session.baseUrl, session.apiKey, boardId) + } + + private suspend fun session(): SessionSnapshot? { + val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() } ?: return null + val apiKey = withContext(ioDispatcher) { + apiKeyStore.getApiKey(baseUrl) + }.getOrNull()?.takeIf { it.isNotBlank() } ?: return null + + return SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey) + } + + private data class SessionSnapshot( + val baseUrl: String, + val apiKey: String, + ) +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt new file mode 100644 index 0000000..0ad0ac8 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModel.kt @@ -0,0 +1,178 @@ +package space.hackenslacker.kanbn4droid.app.boards + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class BoardsUiState( + val isInitialLoading: Boolean = true, + val isRefreshing: Boolean = false, + val isMutating: Boolean = false, + val boards: List = emptyList(), + val templates: List = emptyList(), + val isTemplatesLoading: Boolean = false, +) + +sealed interface BoardsUiEvent { + data class NavigateToBoard(val boardId: String, val boardTitle: String) : BoardsUiEvent + data class ShowServerError(val message: String) : BoardsUiEvent +} + +class BoardsViewModel( + private val repository: BoardsRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(BoardsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + fun loadBoards() { + fetchBoards(initial = true) + } + + fun refreshBoards() { + fetchBoards(initial = false, refresh = true) + } + + fun loadTemplatesIfNeeded() { + val current = _uiState.value + if (current.templates.isNotEmpty() || current.isTemplatesLoading) { + return + } + + viewModelScope.launch { + _uiState.update { it.copy(isTemplatesLoading = true) } + when (val result = repository.listTemplates()) { + is BoardsApiResult.Success -> { + _uiState.update { + it.copy( + templates = result.value, + isTemplatesLoading = false, + ) + } + } + + is BoardsApiResult.Failure -> { + _uiState.update { it.copy(isTemplatesLoading = false) } + _events.emit(BoardsUiEvent.ShowServerError(result.message)) + } + } + } + } + + fun createBoard(name: String, templateId: String?) { + viewModelScope.launch { + _uiState.update { it.copy(isMutating = true) } + when (val result = repository.createBoard(name = name, templateId = templateId)) { + is BoardsApiResult.Success -> { + refetchBoardsAfterMutation() + _events.emit( + BoardsUiEvent.NavigateToBoard( + boardId = result.value.id, + boardTitle = result.value.title, + ), + ) + } + + is BoardsApiResult.Failure -> { + _uiState.update { it.copy(isMutating = false) } + _events.emit(BoardsUiEvent.ShowServerError(result.message)) + } + } + } + } + + fun deleteBoard(board: BoardSummary) { + viewModelScope.launch { + _uiState.update { it.copy(isMutating = true) } + when (val result = repository.deleteBoard(board.id)) { + is BoardsApiResult.Success -> { + refetchBoardsAfterMutation() + } + + is BoardsApiResult.Failure -> { + _uiState.update { it.copy(isMutating = false) } + _events.emit(BoardsUiEvent.ShowServerError(result.message)) + } + } + } + } + + private fun fetchBoards(initial: Boolean, refresh: Boolean = false) { + if (_uiState.value.isMutating) { + return + } + + viewModelScope.launch { + _uiState.update { + it.copy( + isInitialLoading = if (initial) true else it.isInitialLoading, + isRefreshing = refresh, + ) + } + + when (val result = repository.listBoards()) { + is BoardsApiResult.Success -> { + _uiState.update { + it.copy( + boards = result.value, + isInitialLoading = false, + isRefreshing = false, + ) + } + } + + is BoardsApiResult.Failure -> { + _uiState.update { + it.copy( + isInitialLoading = false, + isRefreshing = false, + ) + } + _events.emit(BoardsUiEvent.ShowServerError(result.message)) + } + } + } + } + + private suspend fun refetchBoardsAfterMutation() { + when (val boardsResult = repository.listBoards()) { + is BoardsApiResult.Success -> { + _uiState.update { + it.copy( + boards = boardsResult.value, + isMutating = false, + isInitialLoading = false, + isRefreshing = false, + ) + } + } + + is BoardsApiResult.Failure -> { + _uiState.update { it.copy(isMutating = false) } + _events.emit(BoardsUiEvent.ShowServerError(boardsResult.message)) + } + } + } + + class Factory( + private val repository: BoardsRepository, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(BoardsViewModel::class.java)) { + return BoardsViewModel(repository) as T + } + throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") + } + } +} diff --git a/app/src/main/res/layout/activity_board_detail_placeholder.xml b/app/src/main/res/layout/activity_board_detail_placeholder.xml new file mode 100644 index 0000000..c950c69 --- /dev/null +++ b/app/src/main/res/layout/activity_board_detail_placeholder.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/app/src/main/res/layout/activity_boards.xml b/app/src/main/res/layout/activity_boards.xml new file mode 100644 index 0000000..cc31767 --- /dev/null +++ b/app/src/main/res/layout/activity_boards.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_boards_placeholder.xml b/app/src/main/res/layout/activity_boards_placeholder.xml deleted file mode 100644 index d5e6c2a..0000000 --- a/app/src/main/res/layout/activity_boards_placeholder.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/dialog_create_board.xml b/app/src/main/res/layout/dialog_create_board.xml new file mode 100644 index 0000000..8e2f9c6 --- /dev/null +++ b/app/src/main/res/layout/dialog_create_board.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_board_card.xml b/app/src/main/res/layout/item_board_card.xml new file mode 100644 index 0000000..17c5c40 --- /dev/null +++ b/app/src/main/res/layout/item_board_card.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1cc60ba..c9c8472 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,7 +9,22 @@ Enter your API key Sign in Checking server and signing in... - Boards view coming soon + Boards + No boards yet. Tap + to create one. + Create + Board name + Use template + Template + Board name is required + Select a template + Cancel + Delete + I\'m sure + OK + Delete board "%1$s"? + Are you sure you want to permanently delete "%1$s"? + %1$s\n(id: %2$s) + Board detail view is coming soon. Base URL is required Base URL must start with http:// or https:// Enter a valid server URL diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt new file mode 100644 index 0000000..588a438 --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt @@ -0,0 +1,154 @@ +package space.hackenslacker.kanbn4droid.app.boards + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore +import space.hackenslacker.kanbn4droid.app.auth.AuthResult +import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient +import space.hackenslacker.kanbn4droid.app.auth.SessionStore + +class BoardsRepositoryTest { + + @Test + fun listBoardsFailsWhenMissingSession() = runTest { + val repository = BoardsRepository( + sessionStore = InMemorySessionStore(null), + apiKeyStore = InMemoryApiKeyStore("api"), + apiClient = FakeBoardsApiClient(), + ) + + val result = repository.listBoards() + + assertTrue(result is BoardsApiResult.Failure) + } + + @Test + fun listBoardsReturnsApiDataWhenSessionIsValid() = runTest { + val fakeApi = FakeBoardsApiClient().apply { + listBoardsResult = BoardsApiResult.Success( + listOf(BoardSummary("1", "Alpha"), BoardSummary("2", "Beta")), + ) + } + + val repository = BoardsRepository( + sessionStore = InMemorySessionStore("https://kan.bn/"), + apiKeyStore = InMemoryApiKeyStore("api"), + apiClient = fakeApi, + ) + + val result = repository.listBoards() + + assertTrue(result is BoardsApiResult.Success) + val boards = (result as BoardsApiResult.Success).value + assertEquals(2, boards.size) + assertEquals("Alpha", boards[0].title) + } + + @Test + fun createBoardTrimsNameAndPassesTemplateId() = runTest { + val fakeApi = FakeBoardsApiClient().apply { + createBoardResult = BoardsApiResult.Success(BoardSummary("33", "Roadmap")) + } + + val repository = BoardsRepository( + sessionStore = InMemorySessionStore("https://kan.bn/"), + apiKeyStore = InMemoryApiKeyStore("api"), + apiClient = fakeApi, + ) + + val result = repository.createBoard(" Roadmap ", "tpl-1") + + assertTrue(result is BoardsApiResult.Success) + assertEquals("Roadmap", fakeApi.lastCreateName) + assertEquals("tpl-1", fakeApi.lastCreateTemplateId) + } + + @Test + fun deleteBoardPassesBoardIdToApi() = runTest { + val fakeApi = FakeBoardsApiClient().apply { + deleteBoardResult = BoardsApiResult.Success(Unit) + } + + val repository = BoardsRepository( + sessionStore = InMemorySessionStore("https://kan.bn/"), + apiKeyStore = InMemoryApiKeyStore("api"), + apiClient = fakeApi, + ) + + val result = repository.deleteBoard("42") + + assertTrue(result is BoardsApiResult.Success) + assertEquals("42", fakeApi.lastDeletedId) + } + + private class InMemorySessionStore(private var baseUrl: String?) : SessionStore { + override fun getBaseUrl(): String? = baseUrl + override fun saveBaseUrl(url: String) { + baseUrl = url + } + + override fun clearBaseUrl() { + baseUrl = null + } + } + + private class InMemoryApiKeyStore(private var apiKey: String?) : ApiKeyStore { + override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result { + this.apiKey = apiKey + return Result.success(Unit) + } + + override suspend fun getApiKey(baseUrl: String): Result = Result.success(apiKey) + + override suspend fun invalidateApiKey(baseUrl: String): Result { + apiKey = null + return Result.success(Unit) + } + } + + private class FakeBoardsApiClient : KanbnApiClient { + var listBoardsResult: BoardsApiResult> = BoardsApiResult.Success(emptyList()) + var listTemplatesResult: BoardsApiResult> = BoardsApiResult.Success(emptyList()) + var createBoardResult: BoardsApiResult = BoardsApiResult.Success(BoardSummary("new", "New")) + var deleteBoardResult: BoardsApiResult = BoardsApiResult.Success(Unit) + + var lastCreateName: String? = null + var lastCreateTemplateId: String? = null + var lastDeletedId: String? = null + + override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success + + override suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult> { + return listBoardsResult + } + + override suspend fun listBoardTemplates( + baseUrl: String, + apiKey: String, + ): BoardsApiResult> { + return listTemplatesResult + } + + override suspend fun createBoard( + baseUrl: String, + apiKey: String, + name: String, + templateId: String?, + ): BoardsApiResult { + lastCreateName = name + lastCreateTemplateId = templateId + return createBoardResult + } + + override suspend fun deleteBoard( + baseUrl: String, + apiKey: String, + boardId: String, + ): BoardsApiResult { + lastDeletedId = boardId + return deleteBoardResult + } + } +} diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt new file mode 100644 index 0000000..96f2fbb --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt @@ -0,0 +1,181 @@ +package space.hackenslacker.kanbn4droid.app.boards + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore +import space.hackenslacker.kanbn4droid.app.auth.AuthResult +import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient +import space.hackenslacker.kanbn4droid.app.auth.SessionStore + +@OptIn(ExperimentalCoroutinesApi::class) +class BoardsViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + kotlinx.coroutines.Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + kotlinx.coroutines.Dispatchers.resetMain() + } + + @Test + fun loadBoardsUpdatesUiStateWithBoards() = runTest { + val api = FakeBoardsApiClient().apply { + listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha"))) + } + val viewModel = newViewModel(api) + + viewModel.loadBoards() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isInitialLoading) + assertEquals(1, state.boards.size) + } + + @Test + fun refreshBoardsSetsRefreshingFlagThenResets() = runTest { + val api = FakeBoardsApiClient().apply { + listBoardsResult = BoardsApiResult.Success(emptyList()) + } + val viewModel = newViewModel(api) + + viewModel.refreshBoards() + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isRefreshing) + } + + @Test + fun createBoardSuccessEmitsNavigateEvent() = runTest { + val api = FakeBoardsApiClient().apply { + createBoardResult = BoardsApiResult.Success(BoardSummary("7", "Roadmap")) + listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("7", "Roadmap"))) + } + val viewModel = newViewModel(api) + + val eventDeferred = async { viewModel.events.first() } + + viewModel.createBoard("Roadmap", null) + advanceUntilIdle() + + val event = eventDeferred.await() + assertTrue(event is BoardsUiEvent.NavigateToBoard) + val nav = event as BoardsUiEvent.NavigateToBoard + assertEquals("7", nav.boardId) + } + + @Test + fun createBoardFailureEmitsErrorEvent() = runTest { + val api = FakeBoardsApiClient().apply { + createBoardResult = BoardsApiResult.Failure("Duplicate board") + } + val viewModel = newViewModel(api) + + val eventDeferred = async { viewModel.events.first() } + + viewModel.createBoard("Roadmap", null) + advanceUntilIdle() + + val event = eventDeferred.await() + assertTrue(event is BoardsUiEvent.ShowServerError) + } + + @Test + fun deleteBoardCallsApiAndRefreshesBoards() = runTest { + val api = FakeBoardsApiClient().apply { + listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "A"))) + } + val viewModel = newViewModel(api) + + viewModel.deleteBoard(BoardSummary("1", "A")) + advanceUntilIdle() + + assertEquals("1", api.lastDeletedId) + assertFalse(viewModel.uiState.value.isMutating) + } + + private fun newViewModel(apiClient: FakeBoardsApiClient): BoardsViewModel { + val repository = BoardsRepository( + sessionStore = InMemorySessionStore("https://kan.bn/"), + apiKeyStore = InMemoryApiKeyStore("api"), + apiClient = apiClient, + ioDispatcher = UnconfinedTestDispatcher(), + ) + return BoardsViewModel(repository) + } + + private class InMemorySessionStore(private var baseUrl: String?) : SessionStore { + override fun getBaseUrl(): String? = baseUrl + override fun saveBaseUrl(url: String) { + baseUrl = url + } + + override fun clearBaseUrl() { + baseUrl = null + } + } + + private class InMemoryApiKeyStore(private var apiKey: String?) : ApiKeyStore { + override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result { + this.apiKey = apiKey + return Result.success(Unit) + } + + override suspend fun getApiKey(baseUrl: String): Result = Result.success(apiKey) + + override suspend fun invalidateApiKey(baseUrl: String): Result { + apiKey = null + return Result.success(Unit) + } + } + + private class FakeBoardsApiClient : KanbnApiClient { + var listBoardsResult: BoardsApiResult> = BoardsApiResult.Success(emptyList()) + var createBoardResult: BoardsApiResult = BoardsApiResult.Success(BoardSummary("new", "New")) + var listTemplatesResult: BoardsApiResult> = BoardsApiResult.Success(emptyList()) + var deleteBoardResult: BoardsApiResult = BoardsApiResult.Success(Unit) + + var lastDeletedId: String? = null + + override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success + + override suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult> { + return listBoardsResult + } + + override suspend fun listBoardTemplates(baseUrl: String, apiKey: String): BoardsApiResult> { + return listTemplatesResult + } + + override suspend fun createBoard( + baseUrl: String, + apiKey: String, + name: String, + templateId: String?, + ): BoardsApiResult { + return createBoardResult + } + + override suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + lastDeletedId = boardId + return deleteBoardResult + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aacfbb2..a8b91ad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,9 +9,14 @@ junit = "4.13.2" androidxJunit = "1.2.1" espressoCore = "3.6.1" espressoIntents = "3.6.1" +espressoContrib = "3.6.1" coreSplashscreen = "1.0.1" credentials = "1.3.0" coroutines = "1.10.1" +lifecycle = "2.8.7" +swiperefreshlayout = "1.1.0" +recyclerview = "1.3.2" +activity = "1.9.3" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -22,10 +27,17 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espressoIntents" } +androidx-espresso-contrib = { group = "androidx.test.espresso", name = "espresso-contrib", version.ref = "espressoContrib" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" } androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" } androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentials" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" } +androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" } +androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }