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.
|
- Compile/target SDK: API 35.
|
||||||
- Baseline tests:
|
- Baseline tests:
|
||||||
- JVM unit tests for auth URL normalization and auth error mapping in `app/src/test/`.
|
- 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
|
## 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".
|
- 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".
|
- 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.
|
- 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**
|
**Board detail view**
|
||||||
|
|
||||||
|
|||||||
@@ -50,10 +50,17 @@ dependencies {
|
|||||||
implementation(libs.androidx.credentials.play.services.auth)
|
implementation(libs.androidx.credentials.play.services.auth)
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
implementation(libs.androidx.constraintlayout)
|
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)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.contrib)
|
||||||
androidTestImplementation(libs.androidx.espresso.intents)
|
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)
|
ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|
||||||
Intents.intended(hasComponent(BoardsPlaceholderActivity::class.java.name))
|
Intents.intended(hasComponent(BoardsActivity::class.java.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -91,7 +91,7 @@ class LoginFlowTest {
|
|||||||
onView(withId(R.id.apiKeyInput)).perform(replaceText("kan_new"), closeSoftKeyboard())
|
onView(withId(R.id.apiKeyInput)).perform(replaceText("kan_new"), closeSoftKeyboard())
|
||||||
onView(withId(R.id.signInButton)).perform(click())
|
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("https://kan.bn/", sessionStore.getBaseUrl())
|
||||||
assertEquals("kan_new", keyStore.savedKey)
|
assertEquals("kan_new", keyStore.savedKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.Kanbn4Droid">
|
android:theme="@style/Theme.Kanbn4Droid">
|
||||||
<activity
|
<activity
|
||||||
android:name=".BoardsPlaceholderActivity"
|
android:name=".BoardDetailPlaceholderActivity"
|
||||||
|
android:exported="false" />
|
||||||
|
<activity
|
||||||
|
android:name=".BoardsActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
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)) {
|
return when (apiClient.healthCheck(storedBaseUrl, storedApiKey)) {
|
||||||
AuthResult.Success -> {
|
AuthResult.Success -> {
|
||||||
openBoardsPlaceholder()
|
openBoards()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
if (saveKeyResult.isSuccess) {
|
if (saveKeyResult.isSuccess) {
|
||||||
sessionStore.saveBaseUrl(normalizedBaseUrl)
|
sessionStore.saveBaseUrl(normalizedBaseUrl)
|
||||||
openBoardsPlaceholder()
|
openBoards()
|
||||||
} else {
|
} else {
|
||||||
loginProgress.visibility = View.GONE
|
loginProgress.visibility = View.GONE
|
||||||
statusText.visibility = View.GONE
|
statusText.visibility = View.GONE
|
||||||
@@ -189,21 +189,21 @@ class MainActivity : AppCompatActivity() {
|
|||||||
signInButton.isEnabled = enabled
|
signInButton.isEnabled = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openBoardsPlaceholder() {
|
private fun openBoards() {
|
||||||
startActivity(Intent(this, BoardsPlaceholderActivity::class.java))
|
startActivity(Intent(this, BoardsActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun provideSessionStore(): SessionStore {
|
protected fun provideSessionStore(): SessionStore {
|
||||||
return dependencies.sessionStoreFactory?.invoke(this) ?: SessionPreferences(applicationContext)
|
return dependencies.sessionStoreFactory?.invoke(this) ?: SessionPreferences(applicationContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun provideApiKeyStore(): ApiKeyStore {
|
protected fun provideApiKeyStore(): ApiKeyStore {
|
||||||
return dependencies.apiKeyStoreFactory?.invoke(this)
|
return dependencies.apiKeyStoreFactory?.invoke(this)
|
||||||
?: CredentialManagerApiKeyStore(this)
|
?: CredentialManagerApiKeyStore(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun provideApiClient(): KanbnApiClient {
|
protected fun provideApiClient(): KanbnApiClient {
|
||||||
return dependencies.apiClientFactory?.invoke() ?: HttpKanbnApiClient()
|
return dependencies.apiClientFactory?.invoke() ?: HttpKanbnApiClient()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,37 @@ package space.hackenslacker.kanbn4droid.app.auth
|
|||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
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 {
|
interface KanbnApiClient {
|
||||||
suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult
|
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 {
|
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="api_key_hint">Enter your API key</string>
|
||||||
<string name="sign_in">Sign in</string>
|
<string name="sign_in">Sign in</string>
|
||||||
<string name="logging_in">Checking server and signing 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_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_scheme_error">Base URL must start with http:// or https://</string>
|
||||||
<string name="base_url_invalid">Enter a valid server URL</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"
|
androidxJunit = "1.2.1"
|
||||||
espressoCore = "3.6.1"
|
espressoCore = "3.6.1"
|
||||||
espressoIntents = "3.6.1"
|
espressoIntents = "3.6.1"
|
||||||
|
espressoContrib = "3.6.1"
|
||||||
coreSplashscreen = "1.0.1"
|
coreSplashscreen = "1.0.1"
|
||||||
credentials = "1.3.0"
|
credentials = "1.3.0"
|
||||||
coroutines = "1.10.1"
|
coroutines = "1.10.1"
|
||||||
|
lifecycle = "2.8.7"
|
||||||
|
swiperefreshlayout = "1.1.0"
|
||||||
|
recyclerview = "1.3.2"
|
||||||
|
activity = "1.9.3"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-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-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-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-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" }
|
||||||
androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" }
|
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" }
|
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" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user