feat: implement boards list view workflows

This commit is contained in:
2026-03-15 20:44:07 -04:00
parent 8b8989a839
commit 30f9ac6b98
23 changed files with 1573 additions and 42 deletions

View File

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

View File

@@ -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<Unit> {
key = apiKey
return Result.success(Unit)
}
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(key)
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
key = null
return Result.success(Unit)
}
}
private class FakeBoardsApiClient(
private val boards: MutableList<BoardSummary>,
private val templates: List<BoardTemplate>,
) : KanbnApiClient {
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardSummary>> {
return BoardsApiResult.Success(boards.toList())
}
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<BoardTemplate>> {
return BoardsApiResult.Success(templates)
}
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
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<Unit> {
boards.removeAll { it.id == boardId }
return BoardsApiResult.Success(Unit)
}
}
}

View File

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

View File

@@ -12,7 +12,10 @@
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Kanbn4Droid">
<activity
android:name=".BoardsPlaceholderActivity"
android:name=".BoardDetailPlaceholderActivity"
android:exported="false" />
<activity
android:name=".BoardsActivity"
android:exported="false" />
<activity
android:name=".MainActivity"

View File

@@ -0,0 +1,24 @@
package space.hackenslacker.kanbn4droid.app
import android.os.Bundle
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
class BoardDetailPlaceholderActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_board_detail_placeholder)
val boardTitle = intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
val boardId = intent.getStringExtra(EXTRA_BOARD_ID).orEmpty()
val titleView: TextView = findViewById(R.id.boardDetailPlaceholderTitle)
titleView.text = getString(R.string.board_detail_placeholder_title, boardTitle, boardId)
}
companion object {
const val EXTRA_BOARD_ID = "extra_board_id"
const val EXTRA_BOARD_TITLE = "extra_board_title"
}
}

View File

@@ -0,0 +1,266 @@
package space.hackenslacker.kanbn4droid.app
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.chip.Chip
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.CredentialManagerApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardsAdapter
import space.hackenslacker.kanbn4droid.app.boards.BoardsRepository
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiEvent
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiState
import space.hackenslacker.kanbn4droid.app.boards.BoardsViewModel
class BoardsActivity : AppCompatActivity() {
private lateinit var sessionStore: SessionStore
private lateinit var apiKeyStore: ApiKeyStore
private lateinit var apiClient: KanbnApiClient
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var recyclerView: RecyclerView
private lateinit var emptyStateText: TextView
private lateinit var initialProgress: ProgressBar
private lateinit var createFab: FloatingActionButton
private lateinit var boardsAdapter: BoardsAdapter
private val viewModel: BoardsViewModel by viewModels {
BoardsViewModel.Factory(
BoardsRepository(
sessionStore = sessionStore,
apiKeyStore = apiKeyStore,
apiClient = apiClient,
),
)
}
override fun onCreate(savedInstanceState: Bundle?) {
sessionStore = provideSessionStore()
apiKeyStore = provideApiKeyStore()
apiClient = provideApiClient()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_boards)
bindViews()
setupRecycler()
setupInteractions()
observeViewModel()
viewModel.loadBoards()
}
override fun onResume() {
super.onResume()
viewModel.refreshBoards()
}
private fun bindViews() {
swipeRefresh = findViewById(R.id.boardsSwipeRefresh)
recyclerView = findViewById(R.id.boardsRecyclerView)
emptyStateText = findViewById(R.id.boardsEmptyStateText)
initialProgress = findViewById(R.id.boardsInitialProgress)
createFab = findViewById(R.id.createBoardFab)
}
private fun setupRecycler() {
boardsAdapter = BoardsAdapter(
onBoardClick = { board -> 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()
}
}

View File

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

View File

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

View File

@@ -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<List<BoardSummary>> {
return BoardsApiResult.Failure("Boards listing is not implemented.")
}
suspend fun listBoardTemplates(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardTemplate>> {
return BoardsApiResult.Failure("Board templates listing is not implemented.")
}
suspend fun createBoard(
baseUrl: String,
apiKey: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
return BoardsApiResult.Failure("Board creation is not implemented.")
}
suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
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<List<BoardSummary>> {
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<List<BoardTemplate>> {
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<BoardSummary> {
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<Unit> {
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 <T> request(
baseUrl: String,
path: String,
method: String,
apiKey: String,
body: String? = null,
handler: (code: Int, body: String) -> BoardsApiResult<T>,
): BoardsApiResult<T> {
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<BoardSummary> {
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<BoardSummary> {
val boards = mutableListOf<BoardSummary>()
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<BoardTemplate> {
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<BoardTemplate>()
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"
}
}
}

View File

@@ -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<BoardsAdapter.BoardViewHolder>() {
private val boards = mutableListOf<BoardSummary>()
fun submitBoards(items: List<BoardSummary>) {
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
}
}
}
}

View File

@@ -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<out T> {
data class Success<T>(val value: T) : BoardsApiResult<T>
data class Failure(val message: String) : BoardsApiResult<Nothing>
}

View File

@@ -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<List<BoardSummary>> {
val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
return apiClient.listBoards(session.baseUrl, session.apiKey)
}
suspend fun listTemplates(): BoardsApiResult<List<BoardTemplate>> {
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<BoardSummary> {
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<Unit> {
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,
)
}

View File

@@ -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<BoardSummary> = emptyList(),
val templates: List<BoardTemplate> = 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<BoardsUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<BoardsUiEvent>()
val events: SharedFlow<BoardsUiEvent> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(BoardsViewModel::class.java)) {
return BoardsViewModel(repository) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}
}

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp">
<TextView
android:id="@+id/boardDetailPlaceholderTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/boardDetailPlaceholderSubtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:text="@string/board_detail_placeholder_subtitle"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/boardDetailPlaceholderTitle" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/boardsToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/ThemeOverlay.MaterialComponents.ActionBar"
app:title="@string/boards_title" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/boardsSwipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="?attr/actionBarSize">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/boardsRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="16dp" />
<TextView
android:id="@+id/boardsEmptyStateText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:text="@string/boards_empty_state"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:visibility="gone" />
<ProgressBar
android:id="@+id/boardsInitialProgress"
style="@style/Widget.AppCompat.ProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/createBoardFab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="@string/create_board"
app:srcCompat="@android:drawable/ic_input_add" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp">
<TextView
android:id="@+id/boardsPlaceholderText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/boards_placeholder"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="8dp"
android:paddingEnd="24dp"
android:paddingBottom="8dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/createBoardNameLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/create_board_name_label">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/createBoardNameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapSentences"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.chip.Chip
android:id="@+id/useTemplateChip"
style="@style/Widget.MaterialComponents.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:checkable="true"
android:text="@string/use_template" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/templateSelectorLayout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/template_label"
android:visibility="gone">
<AutoCompleteTextView
android:id="@+id/templateSelectorInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="false"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="120dp"
android:layout_marginBottom="12dp"
app:cardCornerRadius="20dp"
app:cardUseCompatPadding="true">
<TextView
android:id="@+id/boardTitleText"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:padding="16dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6" />
</com.google.android.material.card.MaterialCardView>

View File

@@ -9,7 +9,22 @@
<string name="api_key_hint">Enter your API key</string>
<string name="sign_in">Sign in</string>
<string name="logging_in">Checking server and signing in...</string>
<string name="boards_placeholder">Boards view coming soon</string>
<string name="boards_title">Boards</string>
<string name="boards_empty_state">No boards yet. Tap + to create one.</string>
<string name="create_board">Create</string>
<string name="create_board_name_label">Board name</string>
<string name="use_template">Use template</string>
<string name="template_label">Template</string>
<string name="board_name_required">Board name is required</string>
<string name="template_required">Select a template</string>
<string name="cancel">Cancel</string>
<string name="delete">Delete</string>
<string name="im_sure">I\'m sure</string>
<string name="ok">OK</string>
<string name="delete_board_confirmation">Delete board "%1$s"?</string>
<string name="delete_board_second_confirmation">Are you sure you want to permanently delete "%1$s"?</string>
<string name="board_detail_placeholder_title">%1$s\n(id: %2$s)</string>
<string name="board_detail_placeholder_subtitle">Board detail view is coming soon.</string>
<string name="base_url_required">Base URL is required</string>
<string name="base_url_scheme_error">Base URL must start with http:// or https://</string>
<string name="base_url_invalid">Enter a valid server URL</string>

View File

@@ -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<Unit> {
this.apiKey = apiKey
return Result.success(Unit)
}
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(apiKey)
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
apiKey = null
return Result.success(Unit)
}
}
private class FakeBoardsApiClient : KanbnApiClient {
var listBoardsResult: BoardsApiResult<List<BoardSummary>> = BoardsApiResult.Success(emptyList())
var listTemplatesResult: BoardsApiResult<List<BoardTemplate>> = BoardsApiResult.Success(emptyList())
var createBoardResult: BoardsApiResult<BoardSummary> = BoardsApiResult.Success(BoardSummary("new", "New"))
var deleteBoardResult: BoardsApiResult<Unit> = 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<List<BoardSummary>> {
return listBoardsResult
}
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<BoardTemplate>> {
return listTemplatesResult
}
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
lastCreateName = name
lastCreateTemplateId = templateId
return createBoardResult
}
override suspend fun deleteBoard(
baseUrl: String,
apiKey: String,
boardId: String,
): BoardsApiResult<Unit> {
lastDeletedId = boardId
return deleteBoardResult
}
}
}

View File

@@ -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<Unit> {
this.apiKey = apiKey
return Result.success(Unit)
}
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(apiKey)
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
apiKey = null
return Result.success(Unit)
}
}
private class FakeBoardsApiClient : KanbnApiClient {
var listBoardsResult: BoardsApiResult<List<BoardSummary>> = BoardsApiResult.Success(emptyList())
var createBoardResult: BoardsApiResult<BoardSummary> = BoardsApiResult.Success(BoardSummary("new", "New"))
var listTemplatesResult: BoardsApiResult<List<BoardTemplate>> = BoardsApiResult.Success(emptyList())
var deleteBoardResult: BoardsApiResult<Unit> = 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<List<BoardSummary>> {
return listBoardsResult
}
override suspend fun listBoardTemplates(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardTemplate>> {
return listTemplatesResult
}
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
return createBoardResult
}
override suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
lastDeletedId = boardId
return deleteBoardResult
}
}
}