feat: implement boards list view workflows
This commit is contained in:
@@ -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**
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
60
app/src/main/res/layout/activity_boards.xml
Normal file
60
app/src/main/res/layout/activity_boards.xml
Normal 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>
|
||||
@@ -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>
|
||||
52
app/src/main/res/layout/dialog_create_board.xml
Normal file
52
app/src/main/res/layout/dialog_create_board.xml
Normal 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>
|
||||
18
app/src/main/res/layout/item_board_card.xml
Normal file
18
app/src/main/res/layout/item_board_card.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user