feat: implement boards list view workflows

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

View File

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

View File

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

View File

@@ -0,0 +1,266 @@
package space.hackenslacker.kanbn4droid.app
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.chip.Chip
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.CredentialManagerApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardsAdapter
import space.hackenslacker.kanbn4droid.app.boards.BoardsRepository
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiEvent
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiState
import space.hackenslacker.kanbn4droid.app.boards.BoardsViewModel
class BoardsActivity : AppCompatActivity() {
private lateinit var sessionStore: SessionStore
private lateinit var apiKeyStore: ApiKeyStore
private lateinit var apiClient: KanbnApiClient
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var recyclerView: RecyclerView
private lateinit var emptyStateText: TextView
private lateinit var initialProgress: ProgressBar
private lateinit var createFab: FloatingActionButton
private lateinit var boardsAdapter: BoardsAdapter
private val viewModel: BoardsViewModel by viewModels {
BoardsViewModel.Factory(
BoardsRepository(
sessionStore = sessionStore,
apiKeyStore = apiKeyStore,
apiClient = apiClient,
),
)
}
override fun onCreate(savedInstanceState: Bundle?) {
sessionStore = provideSessionStore()
apiKeyStore = provideApiKeyStore()
apiClient = provideApiClient()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_boards)
bindViews()
setupRecycler()
setupInteractions()
observeViewModel()
viewModel.loadBoards()
}
override fun onResume() {
super.onResume()
viewModel.refreshBoards()
}
private fun bindViews() {
swipeRefresh = findViewById(R.id.boardsSwipeRefresh)
recyclerView = findViewById(R.id.boardsRecyclerView)
emptyStateText = findViewById(R.id.boardsEmptyStateText)
initialProgress = findViewById(R.id.boardsInitialProgress)
createFab = findViewById(R.id.createBoardFab)
}
private fun setupRecycler() {
boardsAdapter = BoardsAdapter(
onBoardClick = { board -> navigateToBoard(board) },
onBoardLongClick = { board -> showDeleteConfirmation(board) },
)
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = boardsAdapter
}
private fun setupInteractions() {
swipeRefresh.setOnRefreshListener {
viewModel.refreshBoards()
}
createFab.setOnClickListener {
showCreateBoardDialog()
}
}
private fun observeViewModel() {
lifecycleScope.launch {
viewModel.uiState.collect { render(it) }
}
lifecycleScope.launch {
viewModel.events.collect { event ->
when (event) {
is BoardsUiEvent.NavigateToBoard -> {
navigateToBoard(BoardSummary(event.boardId, event.boardTitle))
}
is BoardsUiEvent.ShowServerError -> {
MaterialAlertDialogBuilder(this@BoardsActivity)
.setMessage(event.message)
.setPositiveButton(R.string.ok, null)
.show()
}
}
}
}
}
private fun render(state: BoardsUiState) {
boardsAdapter.submitBoards(state.boards)
swipeRefresh.isRefreshing = state.isRefreshing
initialProgress.visibility = if (state.isInitialLoading) View.VISIBLE else View.GONE
emptyStateText.visibility = if (!state.isInitialLoading && state.boards.isEmpty()) View.VISIBLE else View.GONE
createFab.isEnabled = !state.isMutating
}
private fun showCreateBoardDialog() {
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_create_board, null)
val nameLayout: TextInputLayout = dialogView.findViewById(R.id.createBoardNameLayout)
val nameInput: TextInputEditText = dialogView.findViewById(R.id.createBoardNameInput)
val useTemplateChip: Chip = dialogView.findViewById(R.id.useTemplateChip)
val templateLayout: TextInputLayout = dialogView.findViewById(R.id.templateSelectorLayout)
val templateInput: AutoCompleteTextView = dialogView.findViewById(R.id.templateSelectorInput)
var selectedTemplateId: String? = null
var templateCollectorJob: Job? = null
fun bindTemplates() {
val templates = viewModel.uiState.value.templates
templateInput.setAdapter(
ArrayAdapter(
this,
android.R.layout.simple_dropdown_item_1line,
templates.map { it.name },
),
)
if (useTemplateChip.isChecked && selectedTemplateId == null && templates.isNotEmpty()) {
val firstTemplate = templates.first()
selectedTemplateId = firstTemplate.id
templateInput.setText(firstTemplate.name, false)
}
}
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.create_board)
.setView(dialogView)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.create_board, null)
.create()
useTemplateChip.setOnCheckedChangeListener { _, isChecked ->
templateLayout.visibility = if (isChecked) View.VISIBLE else View.GONE
if (isChecked) {
viewModel.loadTemplatesIfNeeded()
bindTemplates()
} else {
selectedTemplateId = null
templateInput.setText("", false)
templateLayout.error = null
}
}
templateInput.setOnItemClickListener { _, _, position, _ ->
val templates = viewModel.uiState.value.templates
selectedTemplateId = templates.getOrNull(position)?.id
templateLayout.error = null
}
dialog.setOnShowListener {
templateCollectorJob = lifecycleScope.launch {
viewModel.uiState.collect {
if (dialog.isShowing && useTemplateChip.isChecked) {
bindTemplates()
}
}
}
val positiveButton: Button = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
positiveButton.setOnClickListener {
nameLayout.error = null
templateLayout.error = null
val boardName = nameInput.text?.toString().orEmpty().trim()
if (boardName.isBlank()) {
nameLayout.error = getString(R.string.board_name_required)
return@setOnClickListener
}
if (useTemplateChip.isChecked && selectedTemplateId.isNullOrBlank()) {
templateLayout.error = getString(R.string.template_required)
return@setOnClickListener
}
viewModel.createBoard(boardName, selectedTemplateId)
dialog.dismiss()
}
}
dialog.setOnDismissListener {
templateCollectorJob?.cancel()
}
dialog.show()
}
private fun showDeleteConfirmation(board: BoardSummary) {
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.delete_board_confirmation, board.title))
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.delete) { _, _ ->
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.delete_board_second_confirmation, board.title))
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.im_sure) { _, _ ->
viewModel.deleteBoard(board)
}
.show()
}
.show()
}
private fun navigateToBoard(board: BoardSummary) {
startActivity(
Intent(this, BoardDetailPlaceholderActivity::class.java)
.putExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_ID, board.id)
.putExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_TITLE, board.title),
)
}
protected fun provideSessionStore(): SessionStore {
return MainActivity.dependencies.sessionStoreFactory?.invoke(this) ?: SessionPreferences(applicationContext)
}
protected fun provideApiKeyStore(): ApiKeyStore {
return MainActivity.dependencies.apiKeyStoreFactory?.invoke(this)
?: CredentialManagerApiKeyStore(this)
}
protected fun provideApiClient(): KanbnApiClient {
return MainActivity.dependencies.apiClientFactory?.invoke() ?: HttpKanbnApiClient()
}
}

View File

@@ -1,11 +0,0 @@
package space.hackenslacker.kanbn4droid.app
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
class BoardsPlaceholderActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_boards_placeholder)
}
}

View File

@@ -102,7 +102,7 @@ class MainActivity : AppCompatActivity() {
return when (apiClient.healthCheck(storedBaseUrl, storedApiKey)) {
AuthResult.Success -> {
openBoardsPlaceholder()
openBoards()
true
}
@@ -156,7 +156,7 @@ class MainActivity : AppCompatActivity() {
}
if (saveKeyResult.isSuccess) {
sessionStore.saveBaseUrl(normalizedBaseUrl)
openBoardsPlaceholder()
openBoards()
} else {
loginProgress.visibility = View.GONE
statusText.visibility = View.GONE
@@ -189,21 +189,21 @@ class MainActivity : AppCompatActivity() {
signInButton.isEnabled = enabled
}
private fun openBoardsPlaceholder() {
startActivity(Intent(this, BoardsPlaceholderActivity::class.java))
private fun openBoards() {
startActivity(Intent(this, BoardsActivity::class.java))
finish()
}
protected open fun provideSessionStore(): SessionStore {
protected fun provideSessionStore(): SessionStore {
return dependencies.sessionStoreFactory?.invoke(this) ?: SessionPreferences(applicationContext)
}
protected open fun provideApiKeyStore(): ApiKeyStore {
protected fun provideApiKeyStore(): ApiKeyStore {
return dependencies.apiKeyStoreFactory?.invoke(this)
?: CredentialManagerApiKeyStore(this)
}
protected open fun provideApiClient(): KanbnApiClient {
protected fun provideApiClient(): KanbnApiClient {
return dependencies.apiClientFactory?.invoke() ?: HttpKanbnApiClient()
}

View File

@@ -3,11 +3,37 @@ package space.hackenslacker.kanbn4droid.app.auth
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import org.json.JSONArray
import org.json.JSONObject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
interface KanbnApiClient {
suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult
suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardSummary>> {
return BoardsApiResult.Failure("Boards listing is not implemented.")
}
suspend fun listBoardTemplates(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardTemplate>> {
return BoardsApiResult.Failure("Board templates listing is not implemented.")
}
suspend fun createBoard(
baseUrl: String,
apiKey: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
return BoardsApiResult.Failure("Board creation is not implemented.")
}
suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Board deletion is not implemented.")
}
}
class HttpKanbnApiClient : KanbnApiClient {
@@ -40,4 +66,225 @@ class HttpKanbnApiClient : KanbnApiClient {
}
}
}
override suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardSummary>> {
return withContext(Dispatchers.IO) {
request(
baseUrl = baseUrl,
path = "/api/v1/boards",
method = "GET",
apiKey = apiKey,
) { code, body ->
if (code in 200..299) {
BoardsApiResult.Success(parseBoards(body))
} else {
BoardsApiResult.Failure(serverMessage(body, code))
}
}
}
}
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<BoardTemplate>> {
return withContext(Dispatchers.IO) {
request(
baseUrl = baseUrl,
path = "/api/v1/board-templates",
method = "GET",
apiKey = apiKey,
) { code, body ->
if (code in 200..299) {
BoardsApiResult.Success(parseTemplates(body))
} else {
BoardsApiResult.Failure(serverMessage(body, code))
}
}
}
}
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
return withContext(Dispatchers.IO) {
val payload = JSONObject().put("title", name)
if (!templateId.isNullOrBlank()) {
payload.put("template_id", templateId)
}
request(
baseUrl = baseUrl,
path = "/api/v1/boards",
method = "POST",
apiKey = apiKey,
body = payload.toString(),
) { code, rawBody ->
if (code in 200..299) {
BoardsApiResult.Success(parseSingleBoard(rawBody, fallbackName = name))
} else {
BoardsApiResult.Failure(serverMessage(rawBody, code))
}
}
}
}
override suspend fun deleteBoard(
baseUrl: String,
apiKey: String,
boardId: String,
): BoardsApiResult<Unit> {
return withContext(Dispatchers.IO) {
request(
baseUrl = baseUrl,
path = "/api/v1/boards/$boardId",
method = "DELETE",
apiKey = apiKey,
) { code, body ->
if (code in 200..299) {
BoardsApiResult.Success(Unit)
} else {
BoardsApiResult.Failure(serverMessage(body, code))
}
}
}
}
private fun <T> request(
baseUrl: String,
path: String,
method: String,
apiKey: String,
body: String? = null,
handler: (code: Int, body: String) -> BoardsApiResult<T>,
): BoardsApiResult<T> {
val endpoint = "${baseUrl.trimEnd('/')}$path"
val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = 10_000
readTimeout = 10_000
setRequestProperty("x-api-key", apiKey)
if (body != null) {
doOutput = true
setRequestProperty("Content-Type", "application/json")
}
}
return try {
if (body != null) {
connection.outputStream.bufferedWriter().use { writer -> writer.write(body) }
}
val code = connection.responseCode
val responseBody = readResponseBody(connection, code)
handler(code, responseBody)
} catch (throwable: Throwable) {
BoardsApiResult.Failure(
AuthErrorMapper.fromException(throwable).message,
)
} finally {
try {
connection.inputStream?.close()
} catch (_: IOException) {
}
try {
connection.errorStream?.close()
} catch (_: IOException) {
}
connection.disconnect()
}
}
private fun readResponseBody(connection: HttpURLConnection, code: Int): String {
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
return stream?.bufferedReader()?.use { it.readText() }.orEmpty()
}
private fun parseBoards(body: String): List<BoardSummary> {
if (body.isBlank()) {
return emptyList()
}
val trimmed = body.trim()
return if (trimmed.startsWith("[")) {
parseBoardsArray(JSONArray(trimmed))
} else {
val root = JSONObject(trimmed)
val candidates = listOf("boards", "items", "data")
val array = candidates.firstNotNullOfOrNull { key -> root.optJSONArray(key) } ?: JSONArray()
parseBoardsArray(array)
}
}
private fun parseBoardsArray(array: JSONArray): List<BoardSummary> {
val boards = mutableListOf<BoardSummary>()
for (index in 0 until array.length()) {
val item = array.optJSONObject(index) ?: continue
val id = item.opt("id")?.toString().orEmpty()
val title = item.optString("title").ifBlank {
item.optString("name").ifBlank { "Board" }
}
if (id.isNotBlank()) {
boards += BoardSummary(id = id, title = title)
}
}
return boards
}
private fun parseSingleBoard(body: String, fallbackName: String): BoardSummary {
if (body.isBlank()) {
return BoardSummary(id = "new", title = fallbackName)
}
val root = JSONObject(body)
val id = root.opt("id")?.toString().orEmpty().ifBlank { root.opt("board_id")?.toString().orEmpty() }
val title = root.optString("title").ifBlank { root.optString("name").ifBlank { fallbackName } }
return BoardSummary(id = if (id.isBlank()) "new" else id, title = title)
}
private fun parseTemplates(body: String): List<BoardTemplate> {
if (body.isBlank()) {
return emptyList()
}
val trimmed = body.trim()
val array = if (trimmed.startsWith("[")) {
JSONArray(trimmed)
} else {
val root = JSONObject(trimmed)
listOf("templates", "items", "data")
.firstNotNullOfOrNull { root.optJSONArray(it) }
?: JSONArray()
}
val templates = mutableListOf<BoardTemplate>()
for (index in 0 until array.length()) {
val item = array.optJSONObject(index) ?: continue
val id = item.opt("id")?.toString().orEmpty()
val name = item.optString("name").ifBlank {
item.optString("title").ifBlank { "Template" }
}
if (id.isNotBlank()) {
templates += BoardTemplate(id = id, name = name)
}
}
return templates
}
private fun serverMessage(body: String, code: Int): String {
if (body.isBlank()) {
return "Server error: $code"
}
return runCatching {
val root = JSONObject(body)
listOf("message", "error", "cause", "detail")
.firstNotNullOfOrNull { key -> root.optString(key).takeIf { it.isNotBlank() } }
?: "Server error: $code"
}.getOrElse {
"Server error: $code"
}
}
}

View File

@@ -0,0 +1,55 @@
package space.hackenslacker.kanbn4droid.app.boards
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import space.hackenslacker.kanbn4droid.app.R
class BoardsAdapter(
private val onBoardClick: (BoardSummary) -> Unit,
private val onBoardLongClick: (BoardSummary) -> Unit,
) : RecyclerView.Adapter<BoardsAdapter.BoardViewHolder>() {
private val boards = mutableListOf<BoardSummary>()
fun submitBoards(items: List<BoardSummary>) {
boards.clear()
boards.addAll(items)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BoardViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_board_card, parent, false)
return BoardViewHolder(view)
}
override fun onBindViewHolder(holder: BoardViewHolder, position: Int) {
val board = boards[position]
holder.bind(
board = board,
onClick = onBoardClick,
onLongClick = onBoardLongClick,
)
}
override fun getItemCount(): Int = boards.size
class BoardViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val titleText: TextView = itemView.findViewById(R.id.boardTitleText)
fun bind(
board: BoardSummary,
onClick: (BoardSummary) -> Unit,
onLongClick: (BoardSummary) -> Unit,
) {
titleText.text = board.title
itemView.setOnClickListener { onClick(board) }
itemView.setOnLongClickListener {
onLongClick(board)
true
}
}
}
}

View File

@@ -0,0 +1,16 @@
package space.hackenslacker.kanbn4droid.app.boards
data class BoardSummary(
val id: String,
val title: String,
)
data class BoardTemplate(
val id: String,
val name: String,
)
sealed interface BoardsApiResult<out T> {
data class Success<T>(val value: T) : BoardsApiResult<T>
data class Failure(val message: String) : BoardsApiResult<Nothing>
}

View File

@@ -0,0 +1,60 @@
package space.hackenslacker.kanbn4droid.app.boards
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
class BoardsRepository(
private val sessionStore: SessionStore,
private val apiKeyStore: ApiKeyStore,
private val apiClient: KanbnApiClient,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun listBoards(): BoardsApiResult<List<BoardSummary>> {
val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
return apiClient.listBoards(session.baseUrl, session.apiKey)
}
suspend fun listTemplates(): BoardsApiResult<List<BoardTemplate>> {
val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
return apiClient.listBoardTemplates(session.baseUrl, session.apiKey)
}
suspend fun createBoard(name: String, templateId: String?): BoardsApiResult<BoardSummary> {
val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
if (name.isBlank()) {
return BoardsApiResult.Failure("Board name is required")
}
return apiClient.createBoard(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
name = name.trim(),
templateId = templateId,
)
}
suspend fun deleteBoard(boardId: String): BoardsApiResult<Unit> {
val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
if (boardId.isBlank()) {
return BoardsApiResult.Failure("Board id is required")
}
return apiClient.deleteBoard(session.baseUrl, session.apiKey, boardId)
}
private suspend fun session(): SessionSnapshot? {
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() } ?: return null
val apiKey = withContext(ioDispatcher) {
apiKeyStore.getApiKey(baseUrl)
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return null
return SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey)
}
private data class SessionSnapshot(
val baseUrl: String,
val apiKey: String,
)
}

View File

@@ -0,0 +1,178 @@
package space.hackenslacker.kanbn4droid.app.boards
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class BoardsUiState(
val isInitialLoading: Boolean = true,
val isRefreshing: Boolean = false,
val isMutating: Boolean = false,
val boards: List<BoardSummary> = emptyList(),
val templates: List<BoardTemplate> = emptyList(),
val isTemplatesLoading: Boolean = false,
)
sealed interface BoardsUiEvent {
data class NavigateToBoard(val boardId: String, val boardTitle: String) : BoardsUiEvent
data class ShowServerError(val message: String) : BoardsUiEvent
}
class BoardsViewModel(
private val repository: BoardsRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(BoardsUiState())
val uiState: StateFlow<BoardsUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<BoardsUiEvent>()
val events: SharedFlow<BoardsUiEvent> = _events.asSharedFlow()
fun loadBoards() {
fetchBoards(initial = true)
}
fun refreshBoards() {
fetchBoards(initial = false, refresh = true)
}
fun loadTemplatesIfNeeded() {
val current = _uiState.value
if (current.templates.isNotEmpty() || current.isTemplatesLoading) {
return
}
viewModelScope.launch {
_uiState.update { it.copy(isTemplatesLoading = true) }
when (val result = repository.listTemplates()) {
is BoardsApiResult.Success -> {
_uiState.update {
it.copy(
templates = result.value,
isTemplatesLoading = false,
)
}
}
is BoardsApiResult.Failure -> {
_uiState.update { it.copy(isTemplatesLoading = false) }
_events.emit(BoardsUiEvent.ShowServerError(result.message))
}
}
}
}
fun createBoard(name: String, templateId: String?) {
viewModelScope.launch {
_uiState.update { it.copy(isMutating = true) }
when (val result = repository.createBoard(name = name, templateId = templateId)) {
is BoardsApiResult.Success -> {
refetchBoardsAfterMutation()
_events.emit(
BoardsUiEvent.NavigateToBoard(
boardId = result.value.id,
boardTitle = result.value.title,
),
)
}
is BoardsApiResult.Failure -> {
_uiState.update { it.copy(isMutating = false) }
_events.emit(BoardsUiEvent.ShowServerError(result.message))
}
}
}
}
fun deleteBoard(board: BoardSummary) {
viewModelScope.launch {
_uiState.update { it.copy(isMutating = true) }
when (val result = repository.deleteBoard(board.id)) {
is BoardsApiResult.Success -> {
refetchBoardsAfterMutation()
}
is BoardsApiResult.Failure -> {
_uiState.update { it.copy(isMutating = false) }
_events.emit(BoardsUiEvent.ShowServerError(result.message))
}
}
}
}
private fun fetchBoards(initial: Boolean, refresh: Boolean = false) {
if (_uiState.value.isMutating) {
return
}
viewModelScope.launch {
_uiState.update {
it.copy(
isInitialLoading = if (initial) true else it.isInitialLoading,
isRefreshing = refresh,
)
}
when (val result = repository.listBoards()) {
is BoardsApiResult.Success -> {
_uiState.update {
it.copy(
boards = result.value,
isInitialLoading = false,
isRefreshing = false,
)
}
}
is BoardsApiResult.Failure -> {
_uiState.update {
it.copy(
isInitialLoading = false,
isRefreshing = false,
)
}
_events.emit(BoardsUiEvent.ShowServerError(result.message))
}
}
}
}
private suspend fun refetchBoardsAfterMutation() {
when (val boardsResult = repository.listBoards()) {
is BoardsApiResult.Success -> {
_uiState.update {
it.copy(
boards = boardsResult.value,
isMutating = false,
isInitialLoading = false,
isRefreshing = false,
)
}
}
is BoardsApiResult.Failure -> {
_uiState.update { it.copy(isMutating = false) }
_events.emit(BoardsUiEvent.ShowServerError(boardsResult.message))
}
}
}
class Factory(
private val repository: BoardsRepository,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(BoardsViewModel::class.java)) {
return BoardsViewModel(repository) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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