Compare commits
21 Commits
6c67628e40
...
81f95c1559
| Author | SHA1 | Date | |
|---|---|---|---|
| 81f95c1559 | |||
| e6f47034bc | |||
| dc493f5037 | |||
| a63799138b | |||
| 28b81c96a0 | |||
| 3d4661cbfd | |||
| 1e5979f5c4 | |||
| 3247892038 | |||
| b936baf564 | |||
| de7bb48fe2 | |||
| c48cd1d525 | |||
| 6d313fdf60 | |||
| 6a18d6679a | |||
| 5e0eff37a6 | |||
| 8022647047 | |||
| 85659f070b | |||
| 7b1c51eae0 | |||
| 3d8b9e4491 | |||
| efe19c794f | |||
| 995a6dcae7 | |||
| 80d4c40f10 |
30
AGENTS.md
30
AGENTS.md
@@ -97,12 +97,17 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
|
|||||||
- 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.
|
||||||
- Long-pressing any of the buttons must show a tooltip with the button name.
|
- Long-pressing any of the buttons must show a tooltip with the button name.
|
||||||
- The view has a floating + button that shows a modal dialog that allows creating a new card using the Kan.bn API.
|
- The view has a floating + button that allows choosing between two options: "Add new list", "Add new card"
|
||||||
- The new card is added to the top of the currently shown list.
|
- The Add new list option shows a modal dialog that asks for a list title. The modal dialog has two buttons at the bottom for "Cancel" and "Create"
|
||||||
- The modal dialog has a field for the card's name. This field is mandatory
|
- The list is created using the Kan.bn API.
|
||||||
- Below the card name field there is a markdown-enabled text area for an optional card description.
|
- The new list is added at the end of the lists in the current board after the current last list.
|
||||||
- Below the card description field there is an optional date field to set the card's due date.
|
- The Add new card dialog shows a modal dialog that works as follows:
|
||||||
- Below the card's due date field there is an optional multi-value selector that allows choosing the card's tags from the tags available for the current board.
|
- The modal dialog has a field for the card's name. This field is mandatory
|
||||||
|
- Below the card name field there is a markdown-enabled text area for an optional card description.
|
||||||
|
- Below the card description field there is an optional date field to set the card's due date.
|
||||||
|
- Below the card's due date field there is an optional multi-value selector that allows choosing the card's tags from the tags available for the current board.
|
||||||
|
- The new card is created using the Kan.bn API.
|
||||||
|
- The new card is added to the top of the currently shown list.
|
||||||
- The title bar of the view has two icon-only buttons for "Filter by tag" (icon is three bars of decreasing width, widest on top) and "Search" (icon is a leaning looking glass)
|
- The title bar of the view has two icon-only buttons for "Filter by tag" (icon is three bars of decreasing width, widest on top) and "Search" (icon is a leaning looking glass)
|
||||||
- The filter by tag button opens a modal dialog that shows a multi-value selector that allows choosing from the tags available on the current board. The modal has a title that says "Filter by tag". The modal has buttons for "Cancel" and "Filter".
|
- The filter by tag button opens a modal dialog that shows a multi-value selector that allows choosing from the tags available on the current board. The modal has a title that says "Filter by tag". The modal has buttons for "Cancel" and "Filter".
|
||||||
- The search button a modal dialog that shows a text field that has the placeholder value "Search". The modal has a title that seas "Search by title". The modal has buttons for "Cancel" and "Search".
|
- The search button a modal dialog that shows a text field that has the placeholder value "Search". The modal has a title that seas "Search by title". The modal has buttons for "Cancel" and "Search".
|
||||||
@@ -113,7 +118,18 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
|
|||||||
- Tapping on the filter by tag or search buttonswhen either of them is applied disables the active filter.
|
- Tapping on the filter by tag or search buttonswhen either of them is applied disables the active filter.
|
||||||
- When a card(s) is selected by a long press, the filter by tag and search buttons get hidden by the select all, move card and delete card buttons until all cards are deselected.
|
- When a card(s) is selected by a long press, the filter by tag and search buttons get hidden by the select all, move card and delete card buttons until all cards are deselected.
|
||||||
- When a card(s) is selected by a long press, the back arrow in the title bar and the back system button remove all selections.
|
- When a card(s) is selected by a long press, the back arrow in the title bar and the back system button remove all selections.
|
||||||
- Current status: implemented in `BoardDetailActivity` with `ViewPager2` (one list per page), inline list-title edit, card rendering (title/tags/due date locale formatting and expiry color), cross-page card selection, page-scoped select-all, move dialog with list selector, two-step delete confirmation, mutation guards while in progress, and API-backed reload/reconciliation behavior through `BoardDetailViewModel` and `BoardDetailRepository`. Card move requests try these variants in order for compatibility across Kan.bn API versions: `PUT /api/v1/cards/{cardPublicId}` with `listPublicId`, then a GET+full-body `PUT /api/v1/cards/{cardPublicId}` payload (`title`, `description`, `index`, `listPublicId`, `dueDate`), then `PUT /api/v1/cards/{cardPublicId}` with `listId`, then `PATCH /api/v1/cards/{cardPublicId}` with `listId`. Board detail parsing now prefers public ids (`publicId`/`public_id`) over internal `id` values so follow-up card/list mutations target the correct API identifiers. Label chip border colors are hydrated from the Kan.bn `Get a label by public ID` endpoint (`colourCode`) and cached in-memory by `BoardDetailRepository` so each label color is fetched only once per app process. Selection action icons use local vector drawables (`ic_select_all_grid_24`, `ic_move_cards_horizontal_24`, `ic_delete_24`) with day/night resource variants so dark mode uses light icon fills automatically. Startup blocking dialogs are shown for missing board id and missing session.
|
- Current status: implemented in `BoardDetailActivity` with `ViewPager2` (one list per page), inline list-title edit, and card rendering (title/tags/due date locale formatting and expiry color).
|
||||||
|
- FAB flows are implemented for both add-list and add-card dialogs.
|
||||||
|
- Filter/search behavior is local (no server roundtrip), and active filter/search icons are highlighted.
|
||||||
|
- Cross-page card selection is implemented. In selection mode, toolbar actions are replaced (filter/search hidden; select-all/move/delete shown).
|
||||||
|
- Select-all is page-scoped, move uses a list selector dialog, and delete uses two-step confirmation.
|
||||||
|
- Back handling clears selection from both the top-bar back arrow and the system back button before navigation.
|
||||||
|
- The screen includes mutation guards while in progress and API-backed reload/reconciliation behavior through `BoardDetailViewModel` and `BoardDetailRepository`.
|
||||||
|
- Card move requests try these variants for Kan.bn API compatibility: `PUT /api/v1/cards/{cardPublicId}` with `listPublicId`, then GET+full-body `PUT /api/v1/cards/{cardPublicId}` payload (`title`, `description`, `index`, `listPublicId`, `dueDate`), then `PUT /api/v1/cards/{cardPublicId}` with `listId`, then `PATCH /api/v1/cards/{cardPublicId}` with `listId`.
|
||||||
|
- Board detail parsing prefers public ids (`publicId`/`public_id`) over internal `id` values so follow-up card/list mutations target correct API identifiers.
|
||||||
|
- Label chip border colors are hydrated from Kan.bn `Get a label by public ID` (`colourCode`) and cached in-memory by `BoardDetailRepository` so each label color is fetched only once per app process.
|
||||||
|
- Selection action icons use local vector drawables (`ic_select_all_grid_24`, `ic_move_cards_horizontal_24`, `ic_delete_24`) with day/night variants so dark mode uses light icon fills automatically.
|
||||||
|
- Startup blocking dialogs are shown for missing board id and missing session.
|
||||||
|
|
||||||
**Card detail view**
|
**Card detail view**
|
||||||
- The view shows the card's title in bold letters. Tapping on the card's title allows editing it.
|
- The view shows the card's title in bold letters. Tapping on the card's title allows editing it.
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import androidx.appcompat.content.res.AppCompatResources
|
|||||||
import androidx.test.core.app.ActivityScenario
|
import androidx.test.core.app.ActivityScenario
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.espresso.Espresso.onView
|
import androidx.test.espresso.Espresso.onView
|
||||||
|
import androidx.test.espresso.Espresso.pressBack
|
||||||
import androidx.test.espresso.action.ViewActions.click
|
import androidx.test.espresso.action.ViewActions.click
|
||||||
import androidx.test.espresso.action.ViewActions.longClick
|
import androidx.test.espresso.action.ViewActions.longClick
|
||||||
import androidx.test.espresso.action.ViewActions.replaceText
|
import androidx.test.espresso.action.ViewActions.replaceText
|
||||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
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.Intents
|
||||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
|
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
|
||||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
|
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
|
||||||
@@ -27,6 +29,8 @@ import androidx.viewpager2.widget.ViewPager2
|
|||||||
import com.google.android.material.color.MaterialColors
|
import com.google.android.material.color.MaterialColors
|
||||||
import com.google.android.material.chip.Chip
|
import com.google.android.material.chip.Chip
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
import java.util.ArrayDeque
|
import java.util.ArrayDeque
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -50,6 +54,7 @@ import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailDataSource
|
|||||||
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail
|
||||||
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary
|
||||||
import space.hackenslacker.kanbn4droid.app.boarddetail.CardBatchMutationResult
|
import space.hackenslacker.kanbn4droid.app.boarddetail.CardBatchMutationResult
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||||
@@ -63,6 +68,7 @@ class BoardDetailFlowTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
|
observedStates.clear()
|
||||||
MainActivity.dependencies.clear()
|
MainActivity.dependencies.clear()
|
||||||
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/") }
|
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/") }
|
||||||
MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore("api") }
|
MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore("api") }
|
||||||
@@ -307,6 +313,251 @@ class BoardDetailFlowTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun toolbarNormalMode_showsFilterAndSearchActions() {
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
onView(withText("Card 1")).check(matches(isDisplayed()))
|
||||||
|
|
||||||
|
awaitCondition {
|
||||||
|
var present = false
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
|
||||||
|
present = menu.findItem(R.id.actionFilterByTag) != null && menu.findItem(R.id.actionSearch) != null
|
||||||
|
}
|
||||||
|
present
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
|
||||||
|
assertNotNull(menu.findItem(R.id.actionFilterByTag))
|
||||||
|
assertNotNull(menu.findItem(R.id.actionSearch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectionMode_replacesFilterSearchWithSelectionActions() {
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("Card 1")).perform(longClick())
|
||||||
|
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
|
||||||
|
assertNotNull(menu.findItem(R.id.actionSelectAll))
|
||||||
|
assertNotNull(menu.findItem(R.id.actionMoveCards))
|
||||||
|
assertNotNull(menu.findItem(R.id.actionDeleteCards))
|
||||||
|
assertNull(menu.findItem(R.id.actionFilterByTag))
|
||||||
|
assertNull(menu.findItem(R.id.actionSearch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fab_isVisible_andOpensAddChooser() {
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withId(R.id.boardDetailCreateFab)).check(matches(isDisplayed()))
|
||||||
|
onView(withId(R.id.boardDetailCreateFab)).perform(click())
|
||||||
|
|
||||||
|
onView(withText("Add new list")).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||||
|
onView(withText("Add new card")).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fabChooser_addList_createAndValidationFlow() {
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withId(R.id.boardDetailCreateFab)).perform(click())
|
||||||
|
onView(withText(R.string.add_new_list)).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withText(R.string.add_new_list)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||||
|
|
||||||
|
onView(withText(R.string.add_list)).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withText(R.string.list_title_required)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||||
|
|
||||||
|
onView(withId(R.id.addListTitleInput)).inRoot(isDialog()).perform(replaceText("Created List"))
|
||||||
|
onView(withText(R.string.add_list)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
awaitCondition {
|
||||||
|
defaultDataSource.createListCalls == 1 && observedStates.lastOrNull()?.isMutating == false
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(1, defaultDataSource.createListCalls)
|
||||||
|
assertEquals("board-1", defaultDataSource.lastCreateListBoardId)
|
||||||
|
assertEquals("Created List", defaultDataSource.lastCreateListTitle)
|
||||||
|
assertTrue(
|
||||||
|
observedStates.lastOrNull()
|
||||||
|
?.boardDetail
|
||||||
|
?.lists
|
||||||
|
?.any { it.title == "Created List" } == true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fabChooser_addCard_createWithTagsAndClearDate() {
|
||||||
|
defaultDataSource.currentDetail = detailCreateCardDialogTagCatalog()
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withId(R.id.boardDetailCreateFab)).perform(click())
|
||||||
|
onView(withText(R.string.add_new_card)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
onView(withId(R.id.addCardTitleInput)).inRoot(isDialog()).perform(replaceText("Created Card"))
|
||||||
|
onView(withId(R.id.addCardDescriptionInput)).inRoot(isDialog()).perform(replaceText("Body"))
|
||||||
|
onView(withText("Backend")).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withId(R.id.addCardDueDateText)).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withText(android.R.string.ok)).inRoot(isDialog()).perform(click())
|
||||||
|
awaitCondition {
|
||||||
|
observedStates.lastOrNull()?.addCardDueDate != null
|
||||||
|
}
|
||||||
|
scenario.onActivity { }
|
||||||
|
onView(withId(R.id.addCardClearDueDateAction)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||||
|
onView(withId(R.id.addCardClearDueDateAction)).inRoot(isDialog()).perform(click())
|
||||||
|
awaitCondition {
|
||||||
|
observedStates.lastOrNull()?.addCardDueDate == null
|
||||||
|
}
|
||||||
|
onView(withText(R.string.add_card)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
awaitCondition {
|
||||||
|
defaultDataSource.createCardCalls == 1 && observedStates.lastOrNull()?.isMutating == false
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(1, defaultDataSource.createCardCalls)
|
||||||
|
assertEquals("list-1", defaultDataSource.lastCreateCardListId)
|
||||||
|
assertEquals("Created Card", defaultDataSource.lastCreateCardTitle)
|
||||||
|
assertEquals("Body", defaultDataSource.lastCreateCardDescription)
|
||||||
|
assertEquals(setOf("tag-1"), defaultDataSource.lastCreateCardTagIds)
|
||||||
|
assertNull(defaultDataSource.lastCreateCardDueDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fabChooser_zeroLists_disablesAddCard_andShowsHelperMessage() {
|
||||||
|
defaultDataSource.currentDetail = BoardDetail(id = "board-1", title = "Board", lists = emptyList())
|
||||||
|
launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withId(R.id.boardDetailCreateFab)).perform(click())
|
||||||
|
onView(withText(R.string.add_new_card)).inRoot(isDialog()).check(matches(not(isEnabled())))
|
||||||
|
onView(withText(R.string.create_a_list_first_to_add_cards)).inRoot(isDialog()).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun filters_applyTagAnyAndSearchAnd_keepAllListsVisible() {
|
||||||
|
defaultDataSource.currentDetail = detailFilterAndSearchThreeLists()
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withContentDescription("Filter by tag")).perform(click())
|
||||||
|
onView(withText("Backend")).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withText("Mobile")).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withText(R.string.filter)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
onView(withContentDescription("Search")).perform(click())
|
||||||
|
onView(withId(R.id.searchTitleInput)).inRoot(isDialog()).perform(replaceText("Duke"))
|
||||||
|
onView(withText(R.string.search)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
onView(withText("Duke Backend")).check(matches(isDisplayed()))
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(1, false)
|
||||||
|
}
|
||||||
|
onView(withText("Duke Mobile")).check(matches(isDisplayed()))
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(2, false)
|
||||||
|
}
|
||||||
|
onView(withText("Done")).check(matches(isDisplayed()))
|
||||||
|
onView(withText(R.string.board_detail_empty_list)).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun filterAndSearchActions_showActiveTintWhenCriteriaApplied_andResetOnClear() {
|
||||||
|
defaultDataSource.currentDetail = detailFilterAndSearchThreeLists()
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withContentDescription("Filter by tag")).perform(click())
|
||||||
|
onView(withText("Backend")).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withText(R.string.filter)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
onView(withContentDescription("Search")).perform(click())
|
||||||
|
onView(withId(R.id.searchTitleInput)).inRoot(isDialog()).perform(replaceText("Duke"))
|
||||||
|
onView(withText(R.string.search)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
|
||||||
|
val expectedActive = MaterialColors.getColor(
|
||||||
|
activity,
|
||||||
|
com.google.android.material.R.attr.colorPrimary,
|
||||||
|
Color.BLUE,
|
||||||
|
)
|
||||||
|
assertEquals(expectedActive, menu.findItem(R.id.actionFilterByTag).iconTintList?.defaultColor)
|
||||||
|
assertEquals(expectedActive, menu.findItem(R.id.actionSearch).iconTintList?.defaultColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
onView(withContentDescription("Filter by tag")).perform(click())
|
||||||
|
onView(withContentDescription("Search")).perform(click())
|
||||||
|
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
|
||||||
|
val expectedInactive = MaterialColors.getColor(
|
||||||
|
activity,
|
||||||
|
com.google.android.material.R.attr.colorOnSurfaceVariant,
|
||||||
|
Color.BLACK,
|
||||||
|
)
|
||||||
|
assertEquals(expectedInactive, menu.findItem(R.id.actionFilterByTag).iconTintList?.defaultColor)
|
||||||
|
assertEquals(expectedInactive, menu.findItem(R.id.actionSearch).iconTintList?.defaultColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun activeFilterOrSearchIconTap_clearsCriterionImmediately() {
|
||||||
|
defaultDataSource.currentDetail = detailFilterAndSearchThreeLists()
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withContentDescription("Filter by tag")).perform(click())
|
||||||
|
onView(withText("Backend")).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withText(R.string.filter)).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withText("Duke Mobile")).check(doesNotExist())
|
||||||
|
|
||||||
|
onView(withContentDescription("Filter by tag")).perform(click())
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(1, false)
|
||||||
|
}
|
||||||
|
onView(withText("Duke Mobile")).check(matches(isDisplayed()))
|
||||||
|
|
||||||
|
onView(withContentDescription("Search")).perform(click())
|
||||||
|
onView(withId(R.id.searchTitleInput)).inRoot(isDialog()).perform(replaceText("Backend"))
|
||||||
|
onView(withText(R.string.search)).inRoot(isDialog()).perform(click())
|
||||||
|
onView(withText(R.string.board_detail_empty_list)).check(matches(isDisplayed()))
|
||||||
|
|
||||||
|
onView(withContentDescription("Search")).perform(click())
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(1, false)
|
||||||
|
}
|
||||||
|
onView(withText("Duke Mobile")).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectionMode_hidesFilterAndSearchActions() {
|
||||||
|
val scenario = launchBoardDetail()
|
||||||
|
|
||||||
|
onView(withText("Card 1")).perform(longClick())
|
||||||
|
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
|
||||||
|
assertNull(menu.findItem(R.id.actionFilterByTag))
|
||||||
|
assertNull(menu.findItem(R.id.actionSearch))
|
||||||
|
assertNotNull(menu.findItem(R.id.actionSelectAll))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun backArrowAndSystemBack_clearSelectionBeforeNavigation() {
|
||||||
|
launchBoardDetail()
|
||||||
|
onView(withText("Card 1")).perform(longClick())
|
||||||
|
onView(withId(R.id.actionSelectAll)).check(matches(isDisplayed()))
|
||||||
|
onView(withContentDescription(androidx.appcompat.R.string.abc_action_bar_up_description)).perform(click())
|
||||||
|
onView(withId(R.id.actionFilterByTag)).check(matches(isDisplayed()))
|
||||||
|
|
||||||
|
launchBoardDetail()
|
||||||
|
onView(withText("Card 1")).perform(longClick())
|
||||||
|
onView(withId(R.id.actionSelectAll)).check(matches(isDisplayed()))
|
||||||
|
pressBack()
|
||||||
|
onView(withId(R.id.actionFilterByTag)).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun missingBoardIdShowsBlockingDialogAndFinishes() {
|
fun missingBoardIdShowsBlockingDialogAndFinishes() {
|
||||||
val scenario = launchBoardDetail(boardId = null)
|
val scenario = launchBoardDetail(boardId = null)
|
||||||
@@ -632,9 +883,88 @@ class BoardDetailFlowTest {
|
|||||||
var deleteGate: CompletableDeferred<Unit>? = null
|
var deleteGate: CompletableDeferred<Unit>? = null
|
||||||
var moveCalls: Int = 0
|
var moveCalls: Int = 0
|
||||||
var deleteCalls: Int = 0
|
var deleteCalls: Int = 0
|
||||||
|
var createListCalls: Int = 0
|
||||||
|
var createCardCalls: Int = 0
|
||||||
var lastMoveCardIds: Set<String> = emptySet()
|
var lastMoveCardIds: Set<String> = emptySet()
|
||||||
var lastDeleteCardIds: Set<String> = emptySet()
|
var lastDeleteCardIds: Set<String> = emptySet()
|
||||||
var lastMoveTargetListId: String? = null
|
var lastMoveTargetListId: String? = null
|
||||||
|
var lastCreateListBoardId: String? = null
|
||||||
|
var lastCreateListTitle: String? = null
|
||||||
|
var lastCreateCardListId: String? = null
|
||||||
|
var lastCreateCardTitle: String? = null
|
||||||
|
var lastCreateCardDescription: String? = null
|
||||||
|
var lastCreateCardDueDate: LocalDate? = null
|
||||||
|
var lastCreateCardTagIds: Set<String> = emptySet()
|
||||||
|
var createListResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("created-list"))
|
||||||
|
var createCardResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("created-card"))
|
||||||
|
|
||||||
|
override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
|
||||||
|
createListCalls += 1
|
||||||
|
lastCreateListBoardId = boardPublicId
|
||||||
|
lastCreateListTitle = title
|
||||||
|
val result = createListResult
|
||||||
|
if (result is BoardsApiResult.Success) {
|
||||||
|
val newId = result.value.publicId?.trim().orEmpty().ifBlank { "created-list-${createListCalls}" }
|
||||||
|
currentDetail = currentDetail.copy(
|
||||||
|
lists = currentDetail.lists + BoardListDetail(
|
||||||
|
id = newId,
|
||||||
|
title = title,
|
||||||
|
cards = emptyList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createCard(
|
||||||
|
listPublicId: String,
|
||||||
|
title: String,
|
||||||
|
description: String?,
|
||||||
|
dueDate: LocalDate?,
|
||||||
|
tagPublicIds: Collection<String>,
|
||||||
|
): BoardsApiResult<CreatedEntityRef> {
|
||||||
|
createCardCalls += 1
|
||||||
|
lastCreateCardListId = listPublicId
|
||||||
|
lastCreateCardTitle = title
|
||||||
|
lastCreateCardDescription = description
|
||||||
|
lastCreateCardDueDate = dueDate
|
||||||
|
lastCreateCardTagIds = tagPublicIds.toSet()
|
||||||
|
|
||||||
|
val result = createCardResult
|
||||||
|
if (result is BoardsApiResult.Success) {
|
||||||
|
val newId = result.value.publicId?.trim().orEmpty().ifBlank { "created-card-${createCardCalls}" }
|
||||||
|
val knownTagsById = currentDetail.lists
|
||||||
|
.asSequence()
|
||||||
|
.flatMap { list -> list.cards.asSequence() }
|
||||||
|
.flatMap { card -> card.tags.asSequence() }
|
||||||
|
.associateBy { tag -> tag.id }
|
||||||
|
val newTags = tagPublicIds.mapNotNull { knownTagsById[it] }
|
||||||
|
val dueAt = dueDate
|
||||||
|
?.atStartOfDay(ZoneId.systemDefault())
|
||||||
|
?.toInstant()
|
||||||
|
?.toEpochMilli()
|
||||||
|
|
||||||
|
currentDetail = currentDetail.copy(
|
||||||
|
lists = currentDetail.lists.map { list ->
|
||||||
|
if (list.id == listPublicId) {
|
||||||
|
list.copy(
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = newId,
|
||||||
|
title = title,
|
||||||
|
tags = newTags,
|
||||||
|
dueAtEpochMillis = dueAt,
|
||||||
|
),
|
||||||
|
) + list.cards,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
list
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
|
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
|
||||||
loadGate?.await()
|
loadGate?.await()
|
||||||
@@ -962,5 +1292,74 @@ class BoardDetailFlowTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun detailCreateCardDialogTagCatalog(): BoardDetail {
|
||||||
|
return BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "card-1",
|
||||||
|
title = "Existing",
|
||||||
|
tags = listOf(
|
||||||
|
BoardTagSummary("tag-1", "Backend", "#008080"),
|
||||||
|
BoardTagSummary("tag-2", "Mobile", "#990000"),
|
||||||
|
),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detailFilterAndSearchThreeLists(): BoardDetail {
|
||||||
|
return BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "todo-duke-backend",
|
||||||
|
title = "Duke Backend",
|
||||||
|
tags = listOf(BoardTagSummary("tag-1", "Backend", "#008080")),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-2",
|
||||||
|
title = "Doing",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "doing-duke-mobile",
|
||||||
|
title = "Duke Mobile",
|
||||||
|
tags = listOf(BoardTagSummary("tag-2", "Mobile", "#009900")),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-3",
|
||||||
|
title = "Done",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "done-archived",
|
||||||
|
title = "Archived",
|
||||||
|
tags = listOf(BoardTagSummary("tag-3", "Ops", "#333333")),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import java.io.IOException
|
|||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -12,6 +13,7 @@ import space.hackenslacker.kanbn4droid.app.boarddetail.BoardCardSummary
|
|||||||
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
|
||||||
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail
|
||||||
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||||
@@ -62,6 +64,28 @@ interface KanbnApiClient {
|
|||||||
return BoardsApiResult.Failure("List rename is not implemented.")
|
return BoardsApiResult.Failure("List rename is not implemented.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun createList(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
boardPublicId: String,
|
||||||
|
title: String,
|
||||||
|
appendIndex: Int,
|
||||||
|
): BoardsApiResult<CreatedEntityRef> {
|
||||||
|
return BoardsApiResult.Failure("List creation is not implemented.")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createCard(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
listPublicId: String,
|
||||||
|
title: String,
|
||||||
|
description: String?,
|
||||||
|
dueDate: LocalDate?,
|
||||||
|
tagPublicIds: List<String>,
|
||||||
|
): BoardsApiResult<CreatedEntityRef> {
|
||||||
|
return BoardsApiResult.Failure("Card creation is not implemented.")
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun moveCard(baseUrl: String, apiKey: String, cardId: String, targetListId: String): BoardsApiResult<Unit> {
|
suspend fun moveCard(baseUrl: String, apiKey: String, cardId: String, targetListId: String): BoardsApiResult<Unit> {
|
||||||
return BoardsApiResult.Failure("Card move is not implemented.")
|
return BoardsApiResult.Failure("Card move is not implemented.")
|
||||||
}
|
}
|
||||||
@@ -269,6 +293,79 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun createList(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
boardPublicId: String,
|
||||||
|
title: String,
|
||||||
|
appendIndex: Int,
|
||||||
|
): BoardsApiResult<CreatedEntityRef> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
val payload =
|
||||||
|
"{" +
|
||||||
|
"\"boardPublicId\":\"${jsonEscape(boardPublicId)}\"," +
|
||||||
|
"\"name\":\"${jsonEscape(title)}\"," +
|
||||||
|
"\"index\":$appendIndex" +
|
||||||
|
"}"
|
||||||
|
request(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = "/api/v1/lists",
|
||||||
|
method = "POST",
|
||||||
|
apiKey = apiKey,
|
||||||
|
body = payload,
|
||||||
|
) { code, body ->
|
||||||
|
if (code in 200..299) {
|
||||||
|
parseCreatedEntityRef(body)
|
||||||
|
?.let { BoardsApiResult.Success(it) }
|
||||||
|
?: BoardsApiResult.Failure("Malformed create list response.")
|
||||||
|
} else {
|
||||||
|
BoardsApiResult.Failure(serverMessage(body, code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createCard(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
listPublicId: String,
|
||||||
|
title: String,
|
||||||
|
description: String?,
|
||||||
|
dueDate: LocalDate?,
|
||||||
|
tagPublicIds: List<String>,
|
||||||
|
): BoardsApiResult<CreatedEntityRef> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
val payloadFields = mutableListOf(
|
||||||
|
"\"listPublicId\":\"${jsonEscape(listPublicId)}\"",
|
||||||
|
"\"title\":\"${jsonEscape(title)}\"",
|
||||||
|
"\"index\":0",
|
||||||
|
"\"labelPublicIds\":${jsonStringArray(tagPublicIds)}",
|
||||||
|
)
|
||||||
|
if (description != null) {
|
||||||
|
payloadFields += "\"description\":\"${jsonEscape(description)}\""
|
||||||
|
}
|
||||||
|
if (dueDate != null) {
|
||||||
|
payloadFields += "\"dueDate\":\"${dueDate}T00:00:00Z\""
|
||||||
|
}
|
||||||
|
val payload = "{${payloadFields.joinToString(",")}}"
|
||||||
|
request(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = "/api/v1/cards",
|
||||||
|
method = "POST",
|
||||||
|
apiKey = apiKey,
|
||||||
|
body = payload,
|
||||||
|
) { code, body ->
|
||||||
|
if (code in 200..299) {
|
||||||
|
parseCreatedEntityRef(body)
|
||||||
|
?.let { BoardsApiResult.Success(it) }
|
||||||
|
?: BoardsApiResult.Failure("Malformed create card response.")
|
||||||
|
} else {
|
||||||
|
BoardsApiResult.Failure(serverMessage(body, code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun moveCard(
|
override suspend fun moveCard(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
@@ -667,6 +764,31 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
return LabelDetail(id = id, colorHex = colorHex)
|
return LabelDetail(id = id, colorHex = colorHex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseCreatedEntityRef(body: String): CreatedEntityRef? {
|
||||||
|
if (body.isBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val root = parseJsonObject(body) ?: return null
|
||||||
|
val data = root["data"]
|
||||||
|
val entity = when (data) {
|
||||||
|
is Map<*, *> -> {
|
||||||
|
(data["card"] as? Map<*, *>)
|
||||||
|
?: (data["list"] as? Map<*, *>)
|
||||||
|
?: data
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
(root["card"] as? Map<*, *>)
|
||||||
|
?: (root["list"] as? Map<*, *>)
|
||||||
|
?: root
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val publicId = extractString(entity, "publicId", "public_id", "id")
|
||||||
|
return CreatedEntityRef(publicId = publicId.ifBlank { null })
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseLists(board: Map<*, *>): List<BoardListDetail> {
|
private fun parseLists(board: Map<*, *>): List<BoardListDetail> {
|
||||||
return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList ->
|
return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList ->
|
||||||
val id = extractId(rawList)
|
val id = extractId(rawList)
|
||||||
@@ -781,6 +903,10 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
return builder.toString()
|
return builder.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun jsonStringArray(values: List<String>): String {
|
||||||
|
return values.joinToString(prefix = "[", postfix = "]", separator = ",") { "\"${jsonEscape(it)}\"" }
|
||||||
|
}
|
||||||
|
|
||||||
private class MiniJsonParser(private val input: String) {
|
private class MiniJsonParser(private val input: String) {
|
||||||
private var index = 0
|
private var index = 0
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,38 @@
|
|||||||
package space.hackenslacker.kanbn4droid.app.boarddetail
|
package space.hackenslacker.kanbn4droid.app.boarddetail
|
||||||
|
|
||||||
|
import android.app.DatePickerDialog
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.widget.CheckBox
|
||||||
|
import android.widget.LinearLayout
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import android.widget.ProgressBar
|
import android.widget.ProgressBar
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.drawable.DrawableCompat
|
||||||
|
import androidx.core.widget.doAfterTextChanged
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.viewpager2.widget.ViewPager2
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
import com.google.android.material.appbar.MaterialToolbar
|
import com.google.android.material.appbar.MaterialToolbar
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
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 com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Date
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import space.hackenslacker.kanbn4droid.app.MainActivity
|
import space.hackenslacker.kanbn4droid.app.MainActivity
|
||||||
import space.hackenslacker.kanbn4droid.app.CardDetailPlaceholderActivity
|
import space.hackenslacker.kanbn4droid.app.CardDetailPlaceholderActivity
|
||||||
@@ -42,8 +58,14 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
private lateinit var fullScreenErrorContainer: View
|
private lateinit var fullScreenErrorContainer: View
|
||||||
private lateinit var fullScreenErrorText: TextView
|
private lateinit var fullScreenErrorText: TextView
|
||||||
private lateinit var retryButton: Button
|
private lateinit var retryButton: Button
|
||||||
|
private lateinit var createFab: FloatingActionButton
|
||||||
|
|
||||||
private var inlineTitleErrorMessage: String? = null
|
private var inlineTitleErrorMessage: String? = null
|
||||||
|
private var fabChooserDialog: AlertDialog? = null
|
||||||
|
private var addListDialog: AlertDialog? = null
|
||||||
|
private var addCardDialog: AlertDialog? = null
|
||||||
|
private var filterDialog: AlertDialog? = null
|
||||||
|
private var searchDialog: AlertDialog? = null
|
||||||
private var moveDialog: AlertDialog? = null
|
private var moveDialog: AlertDialog? = null
|
||||||
private var deleteSecondConfirmationDialog: AlertDialog? = null
|
private var deleteSecondConfirmationDialog: AlertDialog? = null
|
||||||
private var dismissMoveDialogWhenMutationEnds: Boolean = false
|
private var dismissMoveDialogWhenMutationEnds: Boolean = false
|
||||||
@@ -105,6 +127,42 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
|
renderToolbarMenu(menu, viewModel.uiState.value)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
||||||
|
renderToolbarMenu(menu, viewModel.uiState.value)
|
||||||
|
return super.onPrepareOptionsMenu(menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
return when (item.itemId) {
|
||||||
|
R.id.actionFilterByTag -> {
|
||||||
|
viewModel.onTagFilterIconTapped()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.actionSearch -> {
|
||||||
|
viewModel.onSearchIconTapped()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.actionSelectAll,
|
||||||
|
R.id.actionMoveCards,
|
||||||
|
R.id.actionDeleteCards,
|
||||||
|
-> handleSelectionAction(item)
|
||||||
|
|
||||||
|
else -> super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPostResume() {
|
||||||
|
super.onPostResume()
|
||||||
|
renderSelectionActions(viewModel.uiState.value)
|
||||||
|
}
|
||||||
|
|
||||||
private fun bindViews() {
|
private fun bindViews() {
|
||||||
toolbar = findViewById(R.id.boardDetailToolbar)
|
toolbar = findViewById(R.id.boardDetailToolbar)
|
||||||
pager = findViewById(R.id.boardDetailPager)
|
pager = findViewById(R.id.boardDetailPager)
|
||||||
@@ -113,6 +171,7 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
fullScreenErrorContainer = findViewById(R.id.boardDetailFullScreenErrorContainer)
|
fullScreenErrorContainer = findViewById(R.id.boardDetailFullScreenErrorContainer)
|
||||||
fullScreenErrorText = findViewById(R.id.boardDetailFullScreenErrorText)
|
fullScreenErrorText = findViewById(R.id.boardDetailFullScreenErrorText)
|
||||||
retryButton = findViewById(R.id.boardDetailRetryButton)
|
retryButton = findViewById(R.id.boardDetailRetryButton)
|
||||||
|
createFab = findViewById(R.id.boardDetailCreateFab)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupToolbar() {
|
private fun setupToolbar() {
|
||||||
@@ -120,11 +179,14 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
supportActionBar?.title = intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
|
supportActionBar?.title = intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
|
||||||
toolbar.setNavigationOnClickListener {
|
toolbar.setNavigationOnClickListener {
|
||||||
onBackPressedDispatcher.onBackPressed()
|
onBackPressed()
|
||||||
}
|
}
|
||||||
retryButton.setOnClickListener {
|
retryButton.setOnClickListener {
|
||||||
viewModel.retryLoad()
|
viewModel.retryLoad()
|
||||||
}
|
}
|
||||||
|
createFab.setOnClickListener {
|
||||||
|
viewModel.openFabChooser()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupPager() {
|
private fun setupPager() {
|
||||||
@@ -228,7 +290,7 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
initialProgress.visibility = if (state.isInitialLoading && state.boardDetail == null) View.VISIBLE else View.GONE
|
initialProgress.visibility = if (state.isInitialLoading && state.boardDetail == null) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
val boardLists = state.boardDetail?.lists.orEmpty()
|
val boardLists = state.filteredBoardDetail?.lists.orEmpty()
|
||||||
val applyPagerState = {
|
val applyPagerState = {
|
||||||
pagerAdapter.submit(
|
pagerAdapter.submit(
|
||||||
lists = boardLists,
|
lists = boardLists,
|
||||||
@@ -260,7 +322,10 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderSelectionActions(state)
|
renderSelectionActions(state)
|
||||||
|
invalidateOptionsMenu()
|
||||||
renderOpenDialogs(state)
|
renderOpenDialogs(state)
|
||||||
|
createFab.isEnabled = !state.isMutating
|
||||||
|
createFab.visibility = if (state.selectedCardIds.isEmpty()) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showBlockingStartupErrorAndFinish(message: String) {
|
private fun showBlockingStartupErrorAndFinish(message: String) {
|
||||||
@@ -275,6 +340,75 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun renderOpenDialogs(state: BoardDetailUiState) {
|
private fun renderOpenDialogs(state: BoardDetailUiState) {
|
||||||
|
if (state.isFabChooserOpen && fabChooserDialog == null) {
|
||||||
|
showFabChooserDialog(state)
|
||||||
|
} else if (!state.isFabChooserOpen) {
|
||||||
|
fabChooserDialog?.dismiss()
|
||||||
|
fabChooserDialog = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isAddListDialogOpen && addListDialog == null) {
|
||||||
|
showAddListDialog(state)
|
||||||
|
}
|
||||||
|
addListDialog?.let { dialog ->
|
||||||
|
val titleLayout = dialog.findViewById<TextInputLayout>(R.id.addListTitleLayout)
|
||||||
|
val titleInput = dialog.findViewById<TextInputEditText>(R.id.addListTitleInput)
|
||||||
|
titleLayout?.error = state.addListTitleError
|
||||||
|
titleInput?.isEnabled = !state.isMutating
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = !state.isMutating
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
|
||||||
|
if (!state.isAddListDialogOpen) {
|
||||||
|
dialog.dismiss()
|
||||||
|
addListDialog = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isAddCardDialogOpen && addCardDialog == null) {
|
||||||
|
showAddCardDialog(state)
|
||||||
|
}
|
||||||
|
addCardDialog?.let { dialog ->
|
||||||
|
val titleLayout = dialog.findViewById<TextInputLayout>(R.id.addCardTitleLayout)
|
||||||
|
val titleInput = dialog.findViewById<TextInputEditText>(R.id.addCardTitleInput)
|
||||||
|
val descriptionInput = dialog.findViewById<TextInputEditText>(R.id.addCardDescriptionInput)
|
||||||
|
val dueDateText = dialog.findViewById<TextView>(R.id.addCardDueDateText)
|
||||||
|
val clearDueDateAction = dialog.findViewById<TextView>(R.id.addCardClearDueDateAction)
|
||||||
|
titleLayout?.error = state.addCardTitleError
|
||||||
|
titleInput?.isEnabled = !state.isMutating
|
||||||
|
descriptionInput?.isEnabled = !state.isMutating
|
||||||
|
dueDateText?.text = formatDueDateForDialog(state.addCardDueDate)
|
||||||
|
dueDateText?.isEnabled = !state.isMutating
|
||||||
|
clearDueDateAction?.visibility = if (state.addCardDueDate != null) View.VISIBLE else View.GONE
|
||||||
|
clearDueDateAction?.isEnabled = !state.isMutating
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = !state.isMutating
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
|
||||||
|
if (!state.isAddCardDialogOpen) {
|
||||||
|
dialog.dismiss()
|
||||||
|
addCardDialog = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isFilterDialogOpen && filterDialog == null) {
|
||||||
|
showFilterDialog(state)
|
||||||
|
}
|
||||||
|
if (!state.isFilterDialogOpen) {
|
||||||
|
filterDialog?.dismiss()
|
||||||
|
filterDialog = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isSearchDialogOpen && searchDialog == null) {
|
||||||
|
showSearchDialog(state)
|
||||||
|
}
|
||||||
|
searchDialog?.let { dialog ->
|
||||||
|
val input = dialog.findViewById<TextInputEditText>(R.id.searchTitleInput)
|
||||||
|
input?.isEnabled = !state.isMutating
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = !state.isMutating
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
|
||||||
|
if (!state.isSearchDialogOpen) {
|
||||||
|
dialog.dismiss()
|
||||||
|
searchDialog = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val activeMoveDialog = moveDialog
|
val activeMoveDialog = moveDialog
|
||||||
if (activeMoveDialog != null) {
|
if (activeMoveDialog != null) {
|
||||||
val lists = state.boardDetail?.lists.orEmpty()
|
val lists = state.boardDetail?.lists.orEmpty()
|
||||||
@@ -322,18 +456,38 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun renderSelectionActions(state: BoardDetailUiState) {
|
private fun renderSelectionActions(state: BoardDetailUiState) {
|
||||||
val inSelection = state.selectedCardIds.isNotEmpty()
|
// Kept for compatibility with existing call sites; menu rendering is delegated to
|
||||||
toolbar.menu.clear()
|
// framework options menu callbacks.
|
||||||
if (!inSelection) {
|
}
|
||||||
|
|
||||||
|
private fun renderToolbarMenu(menu: Menu, state: BoardDetailUiState) {
|
||||||
|
menu.clear()
|
||||||
|
if (state.selectedCardIds.isNotEmpty()) {
|
||||||
|
menu.add(Menu.NONE, R.id.actionSelectAll, Menu.NONE, getString(R.string.select_all)).apply {
|
||||||
|
setIcon(R.drawable.ic_select_all_grid_24)
|
||||||
|
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||||
|
tooltipText = getString(R.string.select_all)
|
||||||
|
}
|
||||||
|
menu.add(Menu.NONE, R.id.actionMoveCards, Menu.NONE, getString(R.string.move_cards)).apply {
|
||||||
|
setIcon(R.drawable.ic_move_cards_horizontal_24)
|
||||||
|
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||||
|
tooltipText = getString(R.string.move_cards)
|
||||||
|
}
|
||||||
|
menu.add(Menu.NONE, R.id.actionDeleteCards, Menu.NONE, getString(R.string.delete_cards)).apply {
|
||||||
|
setIcon(R.drawable.ic_delete_24)
|
||||||
|
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||||
|
tooltipText = getString(R.string.delete_cards)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toolbar.inflateMenu(R.menu.menu_board_detail_selection)
|
|
||||||
toolbar.menu.findItem(R.id.actionSelectAll)?.tooltipText = getString(R.string.select_all)
|
menuInflater.inflate(R.menu.menu_board_detail_main, menu)
|
||||||
toolbar.menu.findItem(R.id.actionMoveCards)?.tooltipText = getString(R.string.move_cards)
|
val filterItem = menu.findItem(R.id.actionFilterByTag)
|
||||||
toolbar.menu.findItem(R.id.actionDeleteCards)?.tooltipText = getString(R.string.delete_cards)
|
val searchItem = menu.findItem(R.id.actionSearch)
|
||||||
toolbar.setOnMenuItemClickListener { item ->
|
filterItem?.tooltipText = getString(R.string.filter_by_tag)
|
||||||
handleSelectionAction(item)
|
searchItem?.tooltipText = getString(R.string.search)
|
||||||
}
|
tintMainMenuIcon(filterItem, state.activeTagFilterIds.isNotEmpty())
|
||||||
|
tintMainMenuIcon(searchItem, state.activeTitleQuery.isNotBlank())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleSelectionAction(item: MenuItem): Boolean {
|
private fun handleSelectionAction(item: MenuItem): Boolean {
|
||||||
@@ -357,6 +511,241 @@ class BoardDetailActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun tintMainMenuIcon(item: MenuItem?, isActive: Boolean) {
|
||||||
|
val drawableRes = when (item?.itemId) {
|
||||||
|
R.id.actionFilterByTag -> R.drawable.ic_filter_list_24
|
||||||
|
R.id.actionSearch -> R.drawable.ic_search_24
|
||||||
|
else -> null
|
||||||
|
} ?: return
|
||||||
|
val icon = AppCompatResources.getDrawable(this, drawableRes)?.mutate() ?: return
|
||||||
|
val wrapped = DrawableCompat.wrap(icon)
|
||||||
|
val tintColor = if (isActive) {
|
||||||
|
MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, ContextCompat.getColor(this, android.R.color.holo_blue_light))
|
||||||
|
} else {
|
||||||
|
MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurfaceVariant, ContextCompat.getColor(this, android.R.color.black))
|
||||||
|
}
|
||||||
|
DrawableCompat.setTint(wrapped, tintColor)
|
||||||
|
item?.icon = wrapped
|
||||||
|
item?.iconTintList = android.content.res.ColorStateList.valueOf(tintColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showFabChooserDialog(state: BoardDetailUiState) {
|
||||||
|
val root = LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(48, 24, 48, 24)
|
||||||
|
}
|
||||||
|
val addListButton = MaterialButton(this).apply {
|
||||||
|
text = getString(R.string.add_new_list)
|
||||||
|
setOnClickListener {
|
||||||
|
viewModel.openAddListDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val addCardButton = MaterialButton(this).apply {
|
||||||
|
text = getString(R.string.add_new_card)
|
||||||
|
isEnabled = state.canAddCard
|
||||||
|
setOnClickListener {
|
||||||
|
viewModel.openAddCardDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val helperText = TextView(this).apply {
|
||||||
|
text = getString(R.string.create_a_list_first_to_add_cards)
|
||||||
|
visibility = if (state.canAddCard) View.GONE else View.VISIBLE
|
||||||
|
setPadding(8, 4, 8, 0)
|
||||||
|
}
|
||||||
|
root.addView(addListButton)
|
||||||
|
root.addView(addCardButton)
|
||||||
|
root.addView(helperText)
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(this)
|
||||||
|
.setView(root)
|
||||||
|
.setOnDismissListener {
|
||||||
|
if (fabChooserDialog != null) {
|
||||||
|
viewModel.closeFabChooser()
|
||||||
|
}
|
||||||
|
fabChooserDialog = null
|
||||||
|
}
|
||||||
|
.create()
|
||||||
|
fabChooserDialog = dialog
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showAddListDialog(state: BoardDetailUiState) {
|
||||||
|
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_add_list, null)
|
||||||
|
val titleLayout: TextInputLayout = dialogView.findViewById(R.id.addListTitleLayout)
|
||||||
|
val titleInput: TextInputEditText = dialogView.findViewById(R.id.addListTitleInput)
|
||||||
|
titleInput.setText(state.addListTitleDraft)
|
||||||
|
titleInput.doAfterTextChanged { viewModel.updateAddListTitle(it?.toString().orEmpty()) }
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(R.string.add_new_list)
|
||||||
|
.setView(dialogView)
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.setPositiveButton(R.string.add_list, null)
|
||||||
|
.create()
|
||||||
|
dialog.setOnShowListener {
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
|
||||||
|
titleLayout.error = null
|
||||||
|
viewModel.createList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.setOnDismissListener {
|
||||||
|
if (addListDialog != null && viewModel.uiState.value.isAddListDialogOpen) {
|
||||||
|
viewModel.cancelAddListDialog()
|
||||||
|
}
|
||||||
|
addListDialog = null
|
||||||
|
}
|
||||||
|
addListDialog = dialog
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showAddCardDialog(state: BoardDetailUiState) {
|
||||||
|
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_add_card, null)
|
||||||
|
val titleInput: TextInputEditText = dialogView.findViewById(R.id.addCardTitleInput)
|
||||||
|
val descriptionInput: TextInputEditText = dialogView.findViewById(R.id.addCardDescriptionInput)
|
||||||
|
val dueDateText: TextView = dialogView.findViewById(R.id.addCardDueDateText)
|
||||||
|
val clearDueDateAction: TextView = dialogView.findViewById(R.id.addCardClearDueDateAction)
|
||||||
|
val tagsPlaceholderText: TextView = dialogView.findViewById(R.id.addCardTagsPlaceholderText)
|
||||||
|
val tagsContainer: LinearLayout = dialogView.findViewById(R.id.addCardTagsContainer)
|
||||||
|
val tags = state.boardDetail
|
||||||
|
?.lists
|
||||||
|
.orEmpty()
|
||||||
|
.flatMap { it.cards }
|
||||||
|
.flatMap { it.tags }
|
||||||
|
.distinctBy { it.id }
|
||||||
|
.sortedBy { it.name.lowercase() }
|
||||||
|
|
||||||
|
titleInput.setText(state.addCardTitleDraft)
|
||||||
|
descriptionInput.setText(state.addCardDescriptionDraft)
|
||||||
|
titleInput.doAfterTextChanged { viewModel.updateAddCardTitle(it?.toString().orEmpty()) }
|
||||||
|
descriptionInput.doAfterTextChanged { viewModel.updateAddCardDescription(it?.toString().orEmpty()) }
|
||||||
|
dueDateText.text = formatDueDateForDialog(state.addCardDueDate)
|
||||||
|
clearDueDateAction.visibility = if (state.addCardDueDate != null) View.VISIBLE else View.GONE
|
||||||
|
dueDateText.setOnClickListener { openAddCardDatePicker(state.addCardDueDate) }
|
||||||
|
clearDueDateAction.setOnClickListener { viewModel.clearDueDate() }
|
||||||
|
tagsPlaceholderText.visibility = if (tags.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
tags.forEach { tag ->
|
||||||
|
val checkBox = CheckBox(this).apply {
|
||||||
|
text = tag.name
|
||||||
|
isChecked = state.addCardSelectedTagIds.contains(tag.id)
|
||||||
|
setOnCheckedChangeListener { _, _ ->
|
||||||
|
viewModel.toggleAddCardTag(tag.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tagsContainer.addView(checkBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(R.string.add_new_card)
|
||||||
|
.setView(dialogView)
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.setPositiveButton(R.string.add_card, null)
|
||||||
|
.create()
|
||||||
|
dialog.setOnShowListener {
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
|
||||||
|
viewModel.createCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.setOnDismissListener {
|
||||||
|
if (addCardDialog != null && viewModel.uiState.value.isAddCardDialogOpen) {
|
||||||
|
viewModel.cancelAddCardDialog()
|
||||||
|
}
|
||||||
|
addCardDialog = null
|
||||||
|
}
|
||||||
|
addCardDialog = dialog
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAddCardDatePicker(currentDate: java.time.LocalDate?) {
|
||||||
|
val seed = currentDate ?: java.time.LocalDate.now()
|
||||||
|
DatePickerDialog(
|
||||||
|
this,
|
||||||
|
{ _, year, month, dayOfMonth ->
|
||||||
|
viewModel.setDueDate(java.time.LocalDate.of(year, month + 1, dayOfMonth))
|
||||||
|
},
|
||||||
|
seed.year,
|
||||||
|
seed.monthValue - 1,
|
||||||
|
seed.dayOfMonth,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDueDateForDialog(dueDate: java.time.LocalDate?): String {
|
||||||
|
if (dueDate == null) {
|
||||||
|
return getString(R.string.due_date)
|
||||||
|
}
|
||||||
|
val epochMillis = dueDate
|
||||||
|
.atStartOfDay(ZoneId.systemDefault())
|
||||||
|
.toInstant()
|
||||||
|
.toEpochMilli()
|
||||||
|
return DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(epochMillis))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showFilterDialog(state: BoardDetailUiState) {
|
||||||
|
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_filter_tags, null)
|
||||||
|
val container: LinearLayout = dialogView.findViewById(R.id.filterTagsContainer)
|
||||||
|
val tags = state.boardDetail
|
||||||
|
?.lists
|
||||||
|
.orEmpty()
|
||||||
|
.flatMap { it.cards }
|
||||||
|
.flatMap { it.tags }
|
||||||
|
.distinctBy { it.id }
|
||||||
|
.sortedBy { it.name.lowercase() }
|
||||||
|
|
||||||
|
tags.forEach { tag ->
|
||||||
|
val checkBox = CheckBox(this).apply {
|
||||||
|
text = tag.name
|
||||||
|
isChecked = state.pendingTagFilterIds.contains(tag.id)
|
||||||
|
setOnCheckedChangeListener { _, _ ->
|
||||||
|
val selected = mutableSetOf<String>()
|
||||||
|
for (i in 0 until container.childCount) {
|
||||||
|
val child = container.getChildAt(i)
|
||||||
|
if (child is CheckBox && child.isChecked) {
|
||||||
|
selected.add(tags[i].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModel.updatePendingTagFilterIds(selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container.addView(checkBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(R.string.filter_by_tag)
|
||||||
|
.setView(dialogView)
|
||||||
|
.setNegativeButton(R.string.cancel) { _, _ -> viewModel.cancelFilterDialog() }
|
||||||
|
.setPositiveButton(R.string.filter) { _, _ -> viewModel.applyFilterDialog() }
|
||||||
|
.create()
|
||||||
|
dialog.setOnDismissListener {
|
||||||
|
if (filterDialog != null && viewModel.uiState.value.isFilterDialogOpen) {
|
||||||
|
viewModel.cancelFilterDialog()
|
||||||
|
}
|
||||||
|
filterDialog = null
|
||||||
|
}
|
||||||
|
filterDialog = dialog
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSearchDialog(state: BoardDetailUiState) {
|
||||||
|
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_search_title, null)
|
||||||
|
val input: TextInputEditText = dialogView.findViewById(R.id.searchTitleInput)
|
||||||
|
input.setText(state.pendingTitleQuery)
|
||||||
|
input.doAfterTextChanged { viewModel.updatePendingTitleQuery(it?.toString().orEmpty()) }
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(R.string.search_by_title)
|
||||||
|
.setView(dialogView)
|
||||||
|
.setNegativeButton(R.string.cancel) { _, _ -> viewModel.cancelSearchDialog() }
|
||||||
|
.setPositiveButton(R.string.search) { _, _ -> viewModel.applySearchDialog() }
|
||||||
|
.create()
|
||||||
|
dialog.setOnDismissListener {
|
||||||
|
if (searchDialog != null && viewModel.uiState.value.isSearchDialogOpen) {
|
||||||
|
viewModel.cancelSearchDialog()
|
||||||
|
}
|
||||||
|
searchDialog = null
|
||||||
|
}
|
||||||
|
searchDialog = dialog
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
private fun showMoveCardsDialog() {
|
private fun showMoveCardsDialog() {
|
||||||
val lists = viewModel.uiState.value.boardDetail?.lists.orEmpty()
|
val lists = viewModel.uiState.value.boardDetail?.lists.orEmpty()
|
||||||
if (lists.isEmpty()) {
|
if (lists.isEmpty()) {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ data class BoardTagSummary(
|
|||||||
val colorHex: String,
|
val colorHex: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class CreatedEntityRef(
|
||||||
|
val publicId: String?,
|
||||||
|
)
|
||||||
|
|
||||||
sealed interface CardBatchMutationResult {
|
sealed interface CardBatchMutationResult {
|
||||||
data object Success : CardBatchMutationResult
|
data object Success : CardBatchMutationResult
|
||||||
data class PartialSuccess(
|
data class PartialSuccess(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package space.hackenslacker.kanbn4droid.app.boarddetail
|
|||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.time.LocalDate
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.LabelDetail
|
import space.hackenslacker.kanbn4droid.app.auth.LabelDetail
|
||||||
@@ -69,6 +70,81 @@ class BoardDetailRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
|
||||||
|
val normalizedBoardPublicId = boardPublicId.trim()
|
||||||
|
if (normalizedBoardPublicId.isBlank()) {
|
||||||
|
return BoardsApiResult.Failure("Board id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedTitle = title.trim()
|
||||||
|
if (normalizedTitle.isBlank()) {
|
||||||
|
return BoardsApiResult.Failure("List title is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
val session = when (val sessionResult = session()) {
|
||||||
|
is BoardsApiResult.Success -> sessionResult.value
|
||||||
|
is BoardsApiResult.Failure -> return sessionResult
|
||||||
|
}
|
||||||
|
|
||||||
|
val appendIndex = when (
|
||||||
|
val boardDetailResult = apiClient.getBoardDetail(
|
||||||
|
baseUrl = session.baseUrl,
|
||||||
|
apiKey = session.apiKey,
|
||||||
|
boardId = normalizedBoardPublicId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is BoardsApiResult.Success -> boardDetailResult.value.lists.size
|
||||||
|
is BoardsApiResult.Failure -> return BoardsApiResult.Failure(boardDetailResult.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiClient.createList(
|
||||||
|
baseUrl = session.baseUrl,
|
||||||
|
apiKey = session.apiKey,
|
||||||
|
boardPublicId = normalizedBoardPublicId,
|
||||||
|
title = normalizedTitle,
|
||||||
|
appendIndex = appendIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createCard(
|
||||||
|
listPublicId: String,
|
||||||
|
title: String,
|
||||||
|
description: String?,
|
||||||
|
dueDate: LocalDate?,
|
||||||
|
tagPublicIds: Collection<String>,
|
||||||
|
): BoardsApiResult<CreatedEntityRef> {
|
||||||
|
val normalizedListPublicId = listPublicId.trim()
|
||||||
|
if (normalizedListPublicId.isBlank()) {
|
||||||
|
return BoardsApiResult.Failure("List id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedTitle = title.trim()
|
||||||
|
if (normalizedTitle.isBlank()) {
|
||||||
|
return BoardsApiResult.Failure("Card title is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedDescription = description?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
val normalizedTagPublicIds = tagPublicIds
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
val session = when (val sessionResult = session()) {
|
||||||
|
is BoardsApiResult.Success -> sessionResult.value
|
||||||
|
is BoardsApiResult.Failure -> return sessionResult
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiClient.createCard(
|
||||||
|
baseUrl = session.baseUrl,
|
||||||
|
apiKey = session.apiKey,
|
||||||
|
listPublicId = normalizedListPublicId,
|
||||||
|
title = normalizedTitle,
|
||||||
|
description = normalizedDescription,
|
||||||
|
dueDate = dueDate,
|
||||||
|
tagPublicIds = normalizedTagPublicIds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
||||||
val normalizedTargetListId = targetListId.trim()
|
val normalizedTargetListId = targetListId.trim()
|
||||||
if (normalizedTargetListId.isBlank()) {
|
if (normalizedTargetListId.isBlank()) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package space.hackenslacker.kanbn4droid.app.boarddetail
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import java.time.LocalDate
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
@@ -13,6 +14,8 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||||
|
|
||||||
|
private const val ADD_CARD_DISABLED_MESSAGE = "Create a list first to add cards."
|
||||||
|
|
||||||
data class BoardDetailUiState(
|
data class BoardDetailUiState(
|
||||||
val isInitialLoading: Boolean = false,
|
val isInitialLoading: Boolean = false,
|
||||||
val isRefreshing: Boolean = false,
|
val isRefreshing: Boolean = false,
|
||||||
@@ -23,7 +26,35 @@ data class BoardDetailUiState(
|
|||||||
val selectedCardIds: Set<String> = emptySet(),
|
val selectedCardIds: Set<String> = emptySet(),
|
||||||
val editingListId: String? = null,
|
val editingListId: String? = null,
|
||||||
val editingListTitle: String = "",
|
val editingListTitle: String = "",
|
||||||
)
|
val pendingTagFilterIds: Set<String> = emptySet(),
|
||||||
|
val pendingTitleQuery: String = "",
|
||||||
|
val activeTagFilterIds: Set<String> = emptySet(),
|
||||||
|
val activeTitleQuery: String = "",
|
||||||
|
val isFabChooserOpen: Boolean = false,
|
||||||
|
val isAddListDialogOpen: Boolean = false,
|
||||||
|
val isAddCardDialogOpen: Boolean = false,
|
||||||
|
val isFilterDialogOpen: Boolean = false,
|
||||||
|
val isSearchDialogOpen: Boolean = false,
|
||||||
|
val addListTitleDraft: String = "",
|
||||||
|
val addListTitleError: String? = null,
|
||||||
|
val addCardTitleDraft: String = "",
|
||||||
|
val addCardTitleError: String? = null,
|
||||||
|
val addCardDescriptionDraft: String = "",
|
||||||
|
val addCardDueDate: LocalDate? = null,
|
||||||
|
val addCardSelectedTagIds: Set<String> = emptySet(),
|
||||||
|
) {
|
||||||
|
val canAddCard: Boolean
|
||||||
|
get() = boardDetail?.lists?.isNotEmpty() == true
|
||||||
|
|
||||||
|
val addCardDisabledMessage: String?
|
||||||
|
get() = if (canAddCard) null else ADD_CARD_DISABLED_MESSAGE
|
||||||
|
|
||||||
|
val filteredBoardDetail: BoardDetail?
|
||||||
|
get() = boardDetail?.withFilteredCards(
|
||||||
|
tagFilterIds = activeTagFilterIds,
|
||||||
|
titleQuery = activeTitleQuery,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface BoardDetailUiEvent {
|
sealed interface BoardDetailUiEvent {
|
||||||
data class NavigateToCardPlaceholder(val cardId: String) : BoardDetailUiEvent
|
data class NavigateToCardPlaceholder(val cardId: String) : BoardDetailUiEvent
|
||||||
@@ -33,6 +64,15 @@ sealed interface BoardDetailUiEvent {
|
|||||||
|
|
||||||
interface BoardDetailDataSource {
|
interface BoardDetailDataSource {
|
||||||
suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail>
|
suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail>
|
||||||
|
suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef>
|
||||||
|
suspend fun createCard(
|
||||||
|
listPublicId: String,
|
||||||
|
title: String,
|
||||||
|
description: String?,
|
||||||
|
dueDate: LocalDate?,
|
||||||
|
tagPublicIds: Collection<String>,
|
||||||
|
): BoardsApiResult<CreatedEntityRef>
|
||||||
|
|
||||||
suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult
|
suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult
|
||||||
suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult
|
suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult
|
||||||
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit>
|
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit>
|
||||||
@@ -45,6 +85,26 @@ internal class BoardDetailRepositoryDataSource(
|
|||||||
return repository.getBoardDetail(boardId)
|
return repository.getBoardDetail(boardId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
|
||||||
|
return repository.createList(boardPublicId, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createCard(
|
||||||
|
listPublicId: String,
|
||||||
|
title: String,
|
||||||
|
description: String?,
|
||||||
|
dueDate: LocalDate?,
|
||||||
|
tagPublicIds: Collection<String>,
|
||||||
|
): BoardsApiResult<CreatedEntityRef> {
|
||||||
|
return repository.createCard(
|
||||||
|
listPublicId = listPublicId,
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
dueDate = dueDate,
|
||||||
|
tagPublicIds = tagPublicIds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
||||||
return repository.moveCards(cardIds, targetListId)
|
return repository.moveCards(cardIds, targetListId)
|
||||||
}
|
}
|
||||||
@@ -88,7 +148,7 @@ class BoardDetailViewModel(
|
|||||||
|
|
||||||
fun selectAllOnCurrentPage() {
|
fun selectAllOnCurrentPage() {
|
||||||
val current = _uiState.value
|
val current = _uiState.value
|
||||||
val pageCards = current.boardDetail
|
val pageCards = current.filteredBoardDetail
|
||||||
?.lists
|
?.lists
|
||||||
?.getOrNull(current.currentPageIndex)
|
?.getOrNull(current.currentPageIndex)
|
||||||
?.cards
|
?.cards
|
||||||
@@ -129,6 +189,298 @@ class BoardDetailViewModel(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openFabChooser() {
|
||||||
|
_uiState.update { it.copy(isFabChooserOpen = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeFabChooser() {
|
||||||
|
_uiState.update { it.copy(isFabChooserOpen = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openAddListDialog() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isFabChooserOpen = false,
|
||||||
|
isAddListDialogOpen = true,
|
||||||
|
addListTitleError = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateAddListTitle(title: String) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
addListTitleDraft = title,
|
||||||
|
addListTitleError = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAddListDialog() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isAddListDialogOpen = false,
|
||||||
|
addListTitleDraft = "",
|
||||||
|
addListTitleError = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createList() {
|
||||||
|
val snapshot = _uiState.value
|
||||||
|
if (snapshot.isMutating) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val detail = snapshot.boardDetail ?: return
|
||||||
|
val title = snapshot.addListTitleDraft.trim()
|
||||||
|
if (title.isBlank()) {
|
||||||
|
setAddListLocalValidationError("List title is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val expectedIndex = detail.lists.size
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
beginCreateListMutation()
|
||||||
|
when (val result = repository.createList(detail.id, title)) {
|
||||||
|
is BoardsApiResult.Success -> {
|
||||||
|
closeAddListDialogAndResetDrafts()
|
||||||
|
when (val reload = reloadDetailAndReconcile()) {
|
||||||
|
is BoardsApiResult.Success -> {
|
||||||
|
endMutation()
|
||||||
|
val warning = verifyCreatedList(result.value.publicId, expectedIndex, reload.value)
|
||||||
|
if (warning != null) {
|
||||||
|
_events.emit(BoardDetailUiEvent.ShowWarning(warning))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BoardsApiResult.Failure -> {
|
||||||
|
endMutation()
|
||||||
|
emitRefreshFailureWarning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BoardsApiResult.Failure -> {
|
||||||
|
endMutation()
|
||||||
|
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openAddCardDialog() {
|
||||||
|
val snapshot = _uiState.value
|
||||||
|
if (!snapshot.canAddCard) {
|
||||||
|
_uiState.update { it.copy(isFabChooserOpen = false, isAddCardDialogOpen = false) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isFabChooserOpen = false,
|
||||||
|
isAddCardDialogOpen = true,
|
||||||
|
addCardTitleError = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateAddCardTitle(title: String) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
addCardTitleDraft = title,
|
||||||
|
addCardTitleError = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateAddCardDescription(description: String) {
|
||||||
|
_uiState.update { it.copy(addCardDescriptionDraft = description) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDueDate(dueDate: LocalDate) {
|
||||||
|
_uiState.update { it.copy(addCardDueDate = dueDate) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearDueDate() {
|
||||||
|
_uiState.update { it.copy(addCardDueDate = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleAddCardTag(tagId: String) {
|
||||||
|
val normalizedTagId = tagId.trim()
|
||||||
|
if (normalizedTagId.isBlank()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
val next = it.addCardSelectedTagIds.toMutableSet()
|
||||||
|
if (!next.add(normalizedTagId)) {
|
||||||
|
next.remove(normalizedTagId)
|
||||||
|
}
|
||||||
|
it.copy(addCardSelectedTagIds = next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAddCardDialog() {
|
||||||
|
_uiState.update { it.resetAddCardDrafts().copy(isAddCardDialogOpen = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createCard() {
|
||||||
|
val snapshot = _uiState.value
|
||||||
|
if (snapshot.isMutating) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val detail = snapshot.boardDetail ?: return
|
||||||
|
val currentList = detail.lists.getOrNull(snapshot.currentPageIndex) ?: return
|
||||||
|
val title = snapshot.addCardTitleDraft.trim()
|
||||||
|
if (title.isBlank()) {
|
||||||
|
setAddCardLocalValidationError("Card title is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
beginCreateCardMutation()
|
||||||
|
when (
|
||||||
|
val result = repository.createCard(
|
||||||
|
listPublicId = currentList.id,
|
||||||
|
title = title,
|
||||||
|
description = snapshot.addCardDescriptionDraft,
|
||||||
|
dueDate = snapshot.addCardDueDate,
|
||||||
|
tagPublicIds = snapshot.addCardSelectedTagIds,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is BoardsApiResult.Success -> {
|
||||||
|
closeAddCardDialogAndResetDrafts()
|
||||||
|
when (val reload = reloadDetailAndReconcile()) {
|
||||||
|
is BoardsApiResult.Success -> {
|
||||||
|
endMutation()
|
||||||
|
val warning = verifyCreatedCard(
|
||||||
|
createdCardId = result.value.publicId,
|
||||||
|
targetListId = currentList.id,
|
||||||
|
detail = reload.value,
|
||||||
|
)
|
||||||
|
if (warning != null) {
|
||||||
|
_events.emit(BoardDetailUiEvent.ShowWarning(warning))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BoardsApiResult.Failure -> {
|
||||||
|
endMutation()
|
||||||
|
emitRefreshFailureWarning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BoardsApiResult.Failure -> {
|
||||||
|
endMutation()
|
||||||
|
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openFilterDialog() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isFilterDialogOpen = true,
|
||||||
|
pendingTagFilterIds = it.activeTagFilterIds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePendingTagFilterIds(tagIds: Set<String>) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
pendingTagFilterIds = tagIds
|
||||||
|
.map { id -> id.trim() }
|
||||||
|
.filter { id -> id.isNotBlank() }
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyFilterDialog() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
activeTagFilterIds = it.pendingTagFilterIds,
|
||||||
|
isFilterDialogOpen = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelFilterDialog() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
pendingTagFilterIds = it.activeTagFilterIds,
|
||||||
|
isFilterDialogOpen = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTagFilterIconTapped() {
|
||||||
|
val snapshot = _uiState.value
|
||||||
|
if (snapshot.activeTagFilterIds.isNotEmpty()) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
activeTagFilterIds = emptySet(),
|
||||||
|
pendingTagFilterIds = emptySet(),
|
||||||
|
isFilterDialogOpen = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openFilterDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSearchDialog() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isSearchDialogOpen = true,
|
||||||
|
pendingTitleQuery = it.activeTitleQuery,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePendingTitleQuery(query: String) {
|
||||||
|
_uiState.update { it.copy(pendingTitleQuery = query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applySearchDialog() {
|
||||||
|
_uiState.update {
|
||||||
|
val trimmed = it.pendingTitleQuery.trim()
|
||||||
|
it.copy(
|
||||||
|
activeTitleQuery = trimmed,
|
||||||
|
pendingTitleQuery = trimmed,
|
||||||
|
isSearchDialogOpen = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelSearchDialog() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
pendingTitleQuery = it.activeTitleQuery,
|
||||||
|
isSearchDialogOpen = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSearchIconTapped() {
|
||||||
|
val snapshot = _uiState.value
|
||||||
|
if (snapshot.activeTitleQuery.isNotBlank()) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
activeTitleQuery = "",
|
||||||
|
pendingTitleQuery = "",
|
||||||
|
isSearchDialogOpen = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openSearchDialog()
|
||||||
|
}
|
||||||
|
|
||||||
fun moveSelectedCards(targetListId: String) {
|
fun moveSelectedCards(targetListId: String) {
|
||||||
val snapshot = _uiState.value
|
val snapshot = _uiState.value
|
||||||
if (snapshot.isMutating) {
|
if (snapshot.isMutating) {
|
||||||
@@ -191,9 +543,9 @@ class BoardDetailViewModel(
|
|||||||
when (val result = repository.renameList(editingListId, trimmedTitle)) {
|
when (val result = repository.renameList(editingListId, trimmedTitle)) {
|
||||||
is BoardsApiResult.Success -> {
|
is BoardsApiResult.Success -> {
|
||||||
_uiState.update { it.copy(editingListId = null, editingListTitle = "") }
|
_uiState.update { it.copy(editingListId = null, editingListTitle = "") }
|
||||||
val reloadFailureMessage = tryReloadDetailAndReconcile()
|
val reloadResult = reloadDetailAndReconcile()
|
||||||
_uiState.update { it.copy(isMutating = false) }
|
_uiState.update { it.copy(isMutating = false) }
|
||||||
if (reloadFailureMessage != null) {
|
if (reloadResult is BoardsApiResult.Failure) {
|
||||||
_events.emit(
|
_events.emit(
|
||||||
BoardDetailUiEvent.ShowWarning(
|
BoardDetailUiEvent.ShowWarning(
|
||||||
"Changes applied, but refresh failed. Pull to refresh.",
|
"Changes applied, but refresh failed. Pull to refresh.",
|
||||||
@@ -276,9 +628,9 @@ class BoardDetailViewModel(
|
|||||||
when (val result = mutation(selectedIds)) {
|
when (val result = mutation(selectedIds)) {
|
||||||
is CardBatchMutationResult.Success -> {
|
is CardBatchMutationResult.Success -> {
|
||||||
_uiState.update { it.copy(selectedCardIds = emptySet()) }
|
_uiState.update { it.copy(selectedCardIds = emptySet()) }
|
||||||
val reloadFailureMessage = tryReloadDetailAndReconcile()
|
val reloadResult = reloadDetailAndReconcile()
|
||||||
_uiState.update { it.copy(isMutating = false) }
|
_uiState.update { it.copy(isMutating = false) }
|
||||||
if (reloadFailureMessage != null) {
|
if (reloadResult is BoardsApiResult.Failure) {
|
||||||
_events.emit(
|
_events.emit(
|
||||||
BoardDetailUiEvent.ShowWarning(
|
BoardDetailUiEvent.ShowWarning(
|
||||||
"Changes applied, but refresh failed. Pull to refresh.",
|
"Changes applied, but refresh failed. Pull to refresh.",
|
||||||
@@ -288,8 +640,8 @@ class BoardDetailViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is CardBatchMutationResult.PartialSuccess -> {
|
is CardBatchMutationResult.PartialSuccess -> {
|
||||||
val reloadFailureMessage = tryReloadDetailAndReconcile()
|
val reloadResult = reloadDetailAndReconcile()
|
||||||
if (reloadFailureMessage == null) {
|
if (reloadResult is BoardsApiResult.Success) {
|
||||||
val visibleIds = allVisibleCardIds(_uiState.value.boardDetail)
|
val visibleIds = allVisibleCardIds(_uiState.value.boardDetail)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(selectedCardIds = result.failedCardIds.intersect(visibleIds))
|
it.copy(selectedCardIds = result.failedCardIds.intersect(visibleIds))
|
||||||
@@ -325,14 +677,14 @@ class BoardDetailViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun tryReloadDetailAndReconcile(): String? {
|
private suspend fun reloadDetailAndReconcile(): BoardsApiResult<BoardDetail> {
|
||||||
return when (val result = repository.getBoardDetail(boardId)) {
|
return when (val result = repository.getBoardDetail(boardId)) {
|
||||||
is BoardsApiResult.Success -> {
|
is BoardsApiResult.Success -> {
|
||||||
_uiState.update { reconcileWithNewDetail(it, result.value) }
|
_uiState.update { reconcileWithNewDetail(it, result.value) }
|
||||||
null
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
is BoardsApiResult.Failure -> result.message
|
is BoardsApiResult.Failure -> result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +710,8 @@ class BoardDetailViewModel(
|
|||||||
selectedCardIds = prunedSelection,
|
selectedCardIds = prunedSelection,
|
||||||
editingListId = if (hasEditedList) current.editingListId else null,
|
editingListId = if (hasEditedList) current.editingListId else null,
|
||||||
editingListTitle = if (hasEditedList) current.editingListTitle else "",
|
editingListTitle = if (hasEditedList) current.editingListTitle else "",
|
||||||
|
pendingTagFilterIds = current.pendingTagFilterIds.intersect(availableTagIds(detail)),
|
||||||
|
activeTagFilterIds = current.activeTagFilterIds.intersect(availableTagIds(detail)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,6 +731,96 @@ class BoardDetailViewModel(
|
|||||||
.toSet()
|
.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun availableTagIds(detail: BoardDetail): Set<String> {
|
||||||
|
return detail.lists
|
||||||
|
.asSequence()
|
||||||
|
.flatMap { list -> list.cards.asSequence() }
|
||||||
|
.flatMap { card -> card.tags.asSequence() }
|
||||||
|
.map { tag -> tag.id }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verifyCreatedList(createdListId: String?, expectedIndex: Int, detail: BoardDetail): String? {
|
||||||
|
val normalizedId = createdListId?.trim().orEmpty()
|
||||||
|
if (normalizedId.isBlank()) {
|
||||||
|
return CREATE_NON_DETERMINISTIC_WARNING
|
||||||
|
}
|
||||||
|
val actualIndex = detail.lists.indexOfFirst { list -> list.id == normalizedId }
|
||||||
|
if (actualIndex < 0) {
|
||||||
|
return CREATE_NON_DETERMINISTIC_WARNING
|
||||||
|
}
|
||||||
|
return if (actualIndex != expectedIndex) CREATE_LIST_PLACEMENT_WARNING else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verifyCreatedCard(createdCardId: String?, targetListId: String, detail: BoardDetail): String? {
|
||||||
|
val normalizedId = createdCardId?.trim().orEmpty()
|
||||||
|
if (normalizedId.isBlank()) {
|
||||||
|
return CREATE_NON_DETERMINISTIC_WARNING
|
||||||
|
}
|
||||||
|
val list = detail.lists.firstOrNull { it.id == targetListId }
|
||||||
|
?: return CREATE_NON_DETERMINISTIC_WARNING
|
||||||
|
val actualIndex = list.cards.indexOfFirst { card -> card.id == normalizedId }
|
||||||
|
if (actualIndex < 0) {
|
||||||
|
return CREATE_NON_DETERMINISTIC_WARNING
|
||||||
|
}
|
||||||
|
return if (actualIndex != 0) CREATE_CARD_PLACEMENT_WARNING else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setAddListLocalValidationError(message: String) {
|
||||||
|
_uiState.update { it.copy(addListTitleError = message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setAddCardLocalValidationError(message: String) {
|
||||||
|
_uiState.update { it.copy(addCardTitleError = message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun beginCreateListMutation() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isMutating = true,
|
||||||
|
addListTitleError = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun beginCreateCardMutation() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isMutating = true,
|
||||||
|
addCardTitleError = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeAddListDialogAndResetDrafts() {
|
||||||
|
_uiState.update { it.resetAddListDrafts().copy(isAddListDialogOpen = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeAddCardDialogAndResetDrafts() {
|
||||||
|
_uiState.update { it.resetAddCardDrafts().copy(isAddCardDialogOpen = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun endMutation() {
|
||||||
|
_uiState.update { it.copy(isMutating = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun emitRefreshFailureWarning() {
|
||||||
|
_events.emit(
|
||||||
|
BoardDetailUiEvent.ShowWarning(
|
||||||
|
"Changes applied, but refresh failed. Pull to refresh.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val CREATE_LIST_PLACEMENT_WARNING =
|
||||||
|
"List created, but server ordering differed. Pull to refresh if needed."
|
||||||
|
const val CREATE_CARD_PLACEMENT_WARNING =
|
||||||
|
"Card created, but server ordering differed. Pull to refresh if needed."
|
||||||
|
const val CREATE_NON_DETERMINISTIC_WARNING =
|
||||||
|
"Created item could not be deterministically verified. Pull to refresh if needed."
|
||||||
|
}
|
||||||
|
|
||||||
class Factory(
|
class Factory(
|
||||||
private val boardId: String,
|
private val boardId: String,
|
||||||
private val repository: BoardDetailRepository,
|
private val repository: BoardDetailRepository,
|
||||||
@@ -393,3 +837,47 @@ class BoardDetailViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun BoardDetailUiState.resetAddCardDrafts(): BoardDetailUiState {
|
||||||
|
return copy(
|
||||||
|
addCardTitleDraft = "",
|
||||||
|
addCardTitleError = null,
|
||||||
|
addCardDescriptionDraft = "",
|
||||||
|
addCardDueDate = null,
|
||||||
|
addCardSelectedTagIds = emptySet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BoardDetailUiState.resetAddListDrafts(): BoardDetailUiState {
|
||||||
|
return copy(
|
||||||
|
addListTitleDraft = "",
|
||||||
|
addListTitleError = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BoardDetail.withFilteredCards(tagFilterIds: Set<String>, titleQuery: String): BoardDetail {
|
||||||
|
val normalizedTitleQuery = titleQuery.trim()
|
||||||
|
if (tagFilterIds.isEmpty() && normalizedTitleQuery.isEmpty()) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy(
|
||||||
|
lists = lists.map { list ->
|
||||||
|
list.copy(
|
||||||
|
cards = list.cards.filter { card ->
|
||||||
|
val matchesTags = if (tagFilterIds.isEmpty()) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
card.tags.any { tag -> tag.id in tagFilterIds }
|
||||||
|
}
|
||||||
|
val matchesTitle = if (normalizedTitleQuery.isEmpty()) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
card.title.contains(normalizedTitleQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
matchesTags && matchesTitle
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
12
app/src/main/res/drawable/ic_filter_list_24.xml
Normal file
12
app/src/main/res/drawable/ic_filter_list_24.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M4,7h16v2H4zM7,11h10v2H7zM10,15h4v2h-4z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
12
app/src/main/res/drawable/ic_search_24.xml
Normal file
12
app/src/main/res/drawable/ic_search_24.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M15.5,14h-0.79l-0.28,-0.27a6,6 0,1 0,-1.41,1.41l0.27,0.28v0.79L20,21.5L21.5,20zM10,14a4,4 0,1 1,0 -8a4,4 0,0 1,0 8z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -68,4 +68,13 @@
|
|||||||
android:text="@string/retry" />
|
android:text="@string/retry" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
|
android:id="@+id/boardDetailCreateFab"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom|end"
|
||||||
|
android:layout_margin="16dp"
|
||||||
|
android:contentDescription="@string/board_detail_add"
|
||||||
|
app:srcCompat="@android:drawable/ic_input_add" />
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
|
|||||||
102
app/src/main/res/layout/dialog_add_card.xml
Normal file
102
app/src/main/res/layout/dialog_add_card.xml
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
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/addCardTitleLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/card_title">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/addCardTitleInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textCapSentences|text"
|
||||||
|
android:maxLines="1" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:hint="@string/description">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/addCardDescriptionInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="top|start"
|
||||||
|
android:inputType="textMultiLine|textCapSentences"
|
||||||
|
android:minLines="3" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/addCardDueDateRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/addCardDueDateText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/due_date"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/addCardClearDueDateAction"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingStart="8dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingBottom="4dp"
|
||||||
|
android:text="@string/clear_due_date"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/addCardTagSection"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/addCardTagsLabel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/add_card_tags"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.TitleSmall" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/addCardTagsPlaceholderText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/add_card_tags_placeholder"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/addCardTagsContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="vertical" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
25
app/src/main/res/layout/dialog_add_list.xml
Normal file
25
app/src/main/res/layout/dialog_add_list.xml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
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/addListTitleLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/list_title_hint">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/addListTitleInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textCapSentences|text"
|
||||||
|
android:maxLines="1" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
29
app/src/main/res/layout/dialog_filter_tags.xml
Normal file
29
app/src/main/res/layout/dialog_filter_tags.xml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
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">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/filterTagsPlaceholderText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/filter_tags_placeholder"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/filterTagsContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:orientation="vertical" />
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
25
app/src/main/res/layout/dialog_search_title.xml
Normal file
25
app/src/main/res/layout/dialog_search_title.xml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
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/searchTitleLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/search">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/searchTitleInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
16
app/src/main/res/menu/menu_board_detail_main.xml
Normal file
16
app/src/main/res/menu/menu_board_detail_main.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/actionFilterByTag"
|
||||||
|
android:icon="@drawable/ic_filter_list_24"
|
||||||
|
android:title="@string/filter_by_tag"
|
||||||
|
app:showAsAction="always" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/actionSearch"
|
||||||
|
android:icon="@drawable/ic_search_24"
|
||||||
|
android:title="@string/search"
|
||||||
|
app:showAsAction="always" />
|
||||||
|
</menu>
|
||||||
@@ -50,4 +50,22 @@
|
|||||||
<string name="card_detail_placeholder_subtitle">Card detail view is coming soon.</string>
|
<string name="card_detail_placeholder_subtitle">Card detail view is coming soon.</string>
|
||||||
<string name="board_detail_unable_to_open_board">Unable to open board.</string>
|
<string name="board_detail_unable_to_open_board">Unable to open board.</string>
|
||||||
<string name="board_detail_session_expired">Session expired. Please sign in again.</string>
|
<string name="board_detail_session_expired">Session expired. Please sign in again.</string>
|
||||||
|
<string name="board_detail_add">Add</string>
|
||||||
|
<string name="filter_by_tag">Filter by tag</string>
|
||||||
|
<string name="search">Search</string>
|
||||||
|
<string name="search_by_title">Search by title</string>
|
||||||
|
<string name="filter">Filter</string>
|
||||||
|
<string name="add_new_list">Add new list</string>
|
||||||
|
<string name="add_new_card">Add new card</string>
|
||||||
|
<string name="add_list">Add list</string>
|
||||||
|
<string name="add_card">Add card</string>
|
||||||
|
<string name="create_a_list_first_to_add_cards">Create a list first to add cards.</string>
|
||||||
|
<string name="card_title">Card title</string>
|
||||||
|
<string name="card_title_required">Card title is required</string>
|
||||||
|
<string name="description">Description</string>
|
||||||
|
<string name="due_date">Due date</string>
|
||||||
|
<string name="clear_due_date">Clear date</string>
|
||||||
|
<string name="add_card_tags">Tags</string>
|
||||||
|
<string name="add_card_tags_placeholder">Select one or more tags.</string>
|
||||||
|
<string name="filter_tags_placeholder">Select tags to include.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import java.net.InetSocketAddress
|
|||||||
import java.net.ServerSocket
|
import java.net.ServerSocket
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -17,10 +18,160 @@ import org.junit.Assert.assertNull
|
|||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
|
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||||
|
|
||||||
class HttpKanbnApiClientBoardDetailParsingTest {
|
class HttpKanbnApiClientBoardDetailParsingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createList_sendsAppendIndexPayload_andParsesPublicIdFallbacks() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/lists",
|
||||||
|
method = "POST",
|
||||||
|
status = 200,
|
||||||
|
responseBody = """{"list":{"public_id":"list-new"}}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().createList(
|
||||||
|
baseUrl = server.baseUrl,
|
||||||
|
apiKey = "api",
|
||||||
|
boardPublicId = "board-1",
|
||||||
|
title = "Sprint",
|
||||||
|
appendIndex = 7,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success<*>)
|
||||||
|
val createdRef = (result as BoardsApiResult.Success<*>).value as CreatedEntityRef
|
||||||
|
assertEquals("list-new", createdRef.publicId)
|
||||||
|
|
||||||
|
val request = server.findRequest("POST", "/api/v1/lists")
|
||||||
|
assertNotNull(request)
|
||||||
|
assertEquals("{\"boardPublicId\":\"board-1\",\"name\":\"Sprint\",\"index\":7}", request?.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createList_parsesCreatedRefPublicId_fromPublicIdPublic_idAndIdKeys() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.registerSequence(
|
||||||
|
path = "/api/v1/lists",
|
||||||
|
method = "POST",
|
||||||
|
responses = listOf(
|
||||||
|
200 to """{"list":{"publicId":"list-from-publicId"}}""",
|
||||||
|
200 to """{"list":{"public_id":"list-from-public_id"}}""",
|
||||||
|
200 to """{"list":{"id":"list-from-id"}}""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val client = HttpKanbnApiClient()
|
||||||
|
val first = client.createList(server.baseUrl, "api", "board-1", "List A", 0)
|
||||||
|
val second = client.createList(server.baseUrl, "api", "board-1", "List B", 1)
|
||||||
|
val third = client.createList(server.baseUrl, "api", "board-1", "List C", 2)
|
||||||
|
|
||||||
|
assertEquals("list-from-publicId", (first as BoardsApiResult.Success<CreatedEntityRef>).value.publicId)
|
||||||
|
assertEquals("list-from-public_id", (second as BoardsApiResult.Success<CreatedEntityRef>).value.publicId)
|
||||||
|
assertEquals("list-from-id", (third as BoardsApiResult.Success<CreatedEntityRef>).value.publicId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createCard_serializesLocalDateAsUtcMidnight() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(path = "/api/v1/cards", method = "POST", status = 200, responseBody = """{"publicId":"card-new"}""")
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().createCard(
|
||||||
|
baseUrl = server.baseUrl,
|
||||||
|
apiKey = "api",
|
||||||
|
listPublicId = "list-1",
|
||||||
|
title = "Card",
|
||||||
|
description = "Description",
|
||||||
|
dueDate = LocalDate.of(2026, 3, 16),
|
||||||
|
tagPublicIds = listOf("tag-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success<*>)
|
||||||
|
val request = server.findRequest("POST", "/api/v1/cards")
|
||||||
|
assertNotNull(request)
|
||||||
|
assertTrue(request?.body?.contains("\"dueDate\":\"2026-03-16T00:00:00Z\"") == true)
|
||||||
|
assertTrue(request?.body?.contains("\"description\":\"Description\"") == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createCard_sendsTopIndex_andOmittedDueDateWhenNull() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(path = "/api/v1/cards", method = "POST", status = 200, responseBody = """{"publicId":"card-new"}""")
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().createCard(
|
||||||
|
baseUrl = server.baseUrl,
|
||||||
|
apiKey = "api",
|
||||||
|
listPublicId = "list-1",
|
||||||
|
title = "Card",
|
||||||
|
description = null,
|
||||||
|
dueDate = null,
|
||||||
|
tagPublicIds = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success<*>)
|
||||||
|
val request = server.findRequest("POST", "/api/v1/cards")
|
||||||
|
assertNotNull(request)
|
||||||
|
assertTrue(request?.body?.contains("\"index\":0") == true)
|
||||||
|
assertTrue(request?.body?.contains("\"dueDate\"") == false)
|
||||||
|
assertTrue(request?.body?.contains("\"description\"") == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createCard_returnsCreatedRefWithNullPublicIdWhenResponseHasNoId() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/cards",
|
||||||
|
method = "POST",
|
||||||
|
status = 200,
|
||||||
|
responseBody = """{"card":{"title":"Card without id"}}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().createCard(
|
||||||
|
baseUrl = server.baseUrl,
|
||||||
|
apiKey = "api",
|
||||||
|
listPublicId = "list-1",
|
||||||
|
title = "Card",
|
||||||
|
description = null,
|
||||||
|
dueDate = null,
|
||||||
|
tagPublicIds = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success<*>)
|
||||||
|
val createdRef = (result as BoardsApiResult.Success<*>).value as CreatedEntityRef
|
||||||
|
assertNull(createdRef.publicId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createCard_parsesCreatedRefPublicId_fromPublicIdPublic_idAndIdKeys() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.registerSequence(
|
||||||
|
path = "/api/v1/cards",
|
||||||
|
method = "POST",
|
||||||
|
responses = listOf(
|
||||||
|
200 to """{"card":{"publicId":"card-from-publicId"}}""",
|
||||||
|
200 to """{"card":{"public_id":"card-from-public_id"}}""",
|
||||||
|
200 to """{"card":{"id":"card-from-id"}}""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val client = HttpKanbnApiClient()
|
||||||
|
val first = client.createCard(server.baseUrl, "api", "list-1", "Card A", null, null, emptyList())
|
||||||
|
val second = client.createCard(server.baseUrl, "api", "list-1", "Card B", null, null, emptyList())
|
||||||
|
val third = client.createCard(server.baseUrl, "api", "list-1", "Card C", null, null, emptyList())
|
||||||
|
|
||||||
|
assertEquals("card-from-publicId", (first as BoardsApiResult.Success<CreatedEntityRef>).value.publicId)
|
||||||
|
assertEquals("card-from-public_id", (second as BoardsApiResult.Success<CreatedEntityRef>).value.publicId)
|
||||||
|
assertEquals("card-from-id", (third as BoardsApiResult.Success<CreatedEntityRef>).value.publicId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getBoardDetailParsesWrappedPayloadWithDueDateVariants() = runTest {
|
fun getBoardDetailParsesWrappedPayloadWithDueDateVariants() = runTest {
|
||||||
TestServer().use { server ->
|
TestServer().use { server ->
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import org.junit.Test
|
|||||||
|
|
||||||
class BoardDetailModelsTest {
|
class BoardDetailModelsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createdEntityRef_allowsMissingPublicIdForFallbackVerificationPath() {
|
||||||
|
val withPublicId = CreatedEntityRef(publicId = "card-123")
|
||||||
|
val withoutPublicId = CreatedEntityRef(publicId = null)
|
||||||
|
|
||||||
|
assertEquals("card-123", withPublicId.publicId)
|
||||||
|
assertNull(withoutPublicId.publicId)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun boardDetailModelsExposeRequiredFields() {
|
fun boardDetailModelsExposeRequiredFields() {
|
||||||
val tag = BoardTagSummary(
|
val tag = BoardTagSummary(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package space.hackenslacker.kanbn4droid.app.boarddetail
|
package space.hackenslacker.kanbn4droid.app.boarddetail
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import java.time.LocalDate
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -207,6 +208,156 @@ class BoardDetailRepositoryTest {
|
|||||||
assertEquals("New title", apiClient.lastListTitle)
|
assertEquals("New title", apiClient.lastListTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createCard_callsApiWithPublicIdsAndTopIndex() = runTest {
|
||||||
|
val apiClient = FakeBoardDetailApiClient()
|
||||||
|
val repository = createRepository(apiClient = apiClient)
|
||||||
|
|
||||||
|
val result = repository.createCard(
|
||||||
|
listPublicId = " list-1 ",
|
||||||
|
title = " Card title ",
|
||||||
|
description = " Description ",
|
||||||
|
dueDate = LocalDate.of(2026, 3, 16),
|
||||||
|
tagPublicIds = listOf(" tag-1 ", "", "tag-2", "tag-1", " ", " tag-2"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success<*>)
|
||||||
|
assertEquals("list-1", apiClient.lastCreateCardListPublicId)
|
||||||
|
assertEquals("Card title", apiClient.lastCreateCardTitle)
|
||||||
|
assertEquals("Description", apiClient.lastCreateCardDescription)
|
||||||
|
assertEquals(LocalDate.of(2026, 3, 16), apiClient.lastCreateCardDueDate)
|
||||||
|
assertEquals(listOf("tag-1", "tag-2"), apiClient.lastCreateCardTagPublicIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createCard_sendsNullDescriptionWhenBlankAfterTrim() = runTest {
|
||||||
|
val apiClient = FakeBoardDetailApiClient()
|
||||||
|
val repository = createRepository(apiClient = apiClient)
|
||||||
|
|
||||||
|
val result = repository.createCard(
|
||||||
|
listPublicId = "list-1",
|
||||||
|
title = "Card",
|
||||||
|
description = " ",
|
||||||
|
dueDate = null,
|
||||||
|
tagPublicIds = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success<*>)
|
||||||
|
assertEquals(null, apiClient.lastCreateCardDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createCard_delegatesWhenDueDateIsNull() = runTest {
|
||||||
|
val apiClient = FakeBoardDetailApiClient()
|
||||||
|
val repository = createRepository(apiClient = apiClient)
|
||||||
|
|
||||||
|
val result = repository.createCard(
|
||||||
|
listPublicId = "list-1",
|
||||||
|
title = "Card",
|
||||||
|
description = "Description",
|
||||||
|
dueDate = null,
|
||||||
|
tagPublicIds = listOf("tag-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success<*>)
|
||||||
|
assertEquals("list-1", apiClient.lastCreateCardListPublicId)
|
||||||
|
assertEquals("Card", apiClient.lastCreateCardTitle)
|
||||||
|
assertEquals(null, apiClient.lastCreateCardDueDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createListRejectsBlankTitle() = runTest {
|
||||||
|
val repository = createRepository()
|
||||||
|
|
||||||
|
val result = repository.createList(boardPublicId = "board-1", title = " ")
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Failure)
|
||||||
|
assertEquals("List title is required", (result as BoardsApiResult.Failure).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createListRejectsBlankBoardPublicId() = runTest {
|
||||||
|
val repository = createRepository()
|
||||||
|
|
||||||
|
val result = repository.createList(boardPublicId = " ", title = "New List")
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Failure)
|
||||||
|
assertEquals("Board id is required", (result as BoardsApiResult.Failure).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createListDelegatesToApiWithTrimmedIds() = runTest {
|
||||||
|
val apiClient = FakeBoardDetailApiClient().apply {
|
||||||
|
boardDetailResult = BoardsApiResult.Success(
|
||||||
|
BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(id = "list-1", title = "One", cards = emptyList()),
|
||||||
|
BoardListDetail(id = "list-2", title = "Two", cards = emptyList()),
|
||||||
|
BoardListDetail(id = "list-3", title = "Three", cards = emptyList()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val repository = createRepository(apiClient = apiClient)
|
||||||
|
|
||||||
|
val result = repository.createList(boardPublicId = " board-1 ", title = " New List ")
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success<*>)
|
||||||
|
assertEquals("board-1", apiClient.lastBoardId)
|
||||||
|
assertEquals("board-1", apiClient.lastCreateListBoardPublicId)
|
||||||
|
assertEquals("New List", apiClient.lastCreateListTitle)
|
||||||
|
assertEquals(3, apiClient.lastCreateListAppendIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createListPropagatesFailureWhenBoardDetailPrefetchFails() = runTest {
|
||||||
|
val apiClient = FakeBoardDetailApiClient().apply {
|
||||||
|
boardDetailResult = BoardsApiResult.Failure("Cannot load board")
|
||||||
|
}
|
||||||
|
val repository = createRepository(apiClient = apiClient)
|
||||||
|
|
||||||
|
val result = repository.createList(boardPublicId = "board-1", title = "New List")
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Failure)
|
||||||
|
assertEquals("Cannot load board", (result as BoardsApiResult.Failure).message)
|
||||||
|
assertEquals("board-1", apiClient.lastBoardId)
|
||||||
|
assertEquals(null, apiClient.lastCreateListBoardPublicId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createCardRejectsBlankListPublicId() = runTest {
|
||||||
|
val repository = createRepository()
|
||||||
|
|
||||||
|
val result = repository.createCard(
|
||||||
|
listPublicId = " ",
|
||||||
|
title = "Card",
|
||||||
|
description = null,
|
||||||
|
dueDate = null,
|
||||||
|
tagPublicIds = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Failure)
|
||||||
|
assertEquals("List id is required", (result as BoardsApiResult.Failure).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createCardRejectsBlankTitle() = runTest {
|
||||||
|
val repository = createRepository()
|
||||||
|
|
||||||
|
val result = repository.createCard(
|
||||||
|
listPublicId = "list-1",
|
||||||
|
title = " ",
|
||||||
|
description = null,
|
||||||
|
dueDate = null,
|
||||||
|
tagPublicIds = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Failure)
|
||||||
|
assertEquals("Card title is required", (result as BoardsApiResult.Failure).message)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getBoardDetailValidatesBoardId() = runTest {
|
fun getBoardDetailValidatesBoardId() = runTest {
|
||||||
val repository = createRepository()
|
val repository = createRepository()
|
||||||
@@ -454,6 +605,8 @@ class BoardDetailRepositoryTest {
|
|||||||
BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
|
BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
|
||||||
var boardDetailResult: BoardsApiResult<BoardDetail> = BoardsApiResult.Success(sampleBoardDetail())
|
var boardDetailResult: BoardsApiResult<BoardDetail> = BoardsApiResult.Success(sampleBoardDetail())
|
||||||
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
|
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
|
||||||
|
var createListResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("list-new"))
|
||||||
|
var createCardResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("card-new"))
|
||||||
var moveOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
|
var moveOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
|
||||||
var deleteOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
|
var deleteOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
|
||||||
var labelByIdResults: Map<String, BoardsApiResult<LabelDetail>> = emptyMap()
|
var labelByIdResults: Map<String, BoardsApiResult<LabelDetail>> = emptyMap()
|
||||||
@@ -466,6 +619,14 @@ class BoardDetailRepositoryTest {
|
|||||||
var deletedCardIds: MutableList<String> = mutableListOf()
|
var deletedCardIds: MutableList<String> = mutableListOf()
|
||||||
var lastMoveTargetListId: String? = null
|
var lastMoveTargetListId: String? = null
|
||||||
var getLabelByPublicIdCalls: MutableList<String> = mutableListOf()
|
var getLabelByPublicIdCalls: MutableList<String> = mutableListOf()
|
||||||
|
var lastCreateListBoardPublicId: String? = null
|
||||||
|
var lastCreateListTitle: String? = null
|
||||||
|
var lastCreateListAppendIndex: Int? = null
|
||||||
|
var lastCreateCardListPublicId: String? = null
|
||||||
|
var lastCreateCardTitle: String? = null
|
||||||
|
var lastCreateCardDescription: String? = null
|
||||||
|
var lastCreateCardDueDate: LocalDate? = null
|
||||||
|
var lastCreateCardTagPublicIds: List<String> = emptyList()
|
||||||
|
|
||||||
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
|
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
|
||||||
|
|
||||||
@@ -497,6 +658,36 @@ class BoardDetailRepositoryTest {
|
|||||||
return renameListResult
|
return renameListResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun createList(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
boardPublicId: String,
|
||||||
|
title: String,
|
||||||
|
appendIndex: Int,
|
||||||
|
): BoardsApiResult<CreatedEntityRef> {
|
||||||
|
lastCreateListBoardPublicId = boardPublicId
|
||||||
|
lastCreateListTitle = title
|
||||||
|
lastCreateListAppendIndex = appendIndex
|
||||||
|
return createListResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createCard(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
listPublicId: String,
|
||||||
|
title: String,
|
||||||
|
description: String?,
|
||||||
|
dueDate: LocalDate?,
|
||||||
|
tagPublicIds: List<String>,
|
||||||
|
): BoardsApiResult<CreatedEntityRef> {
|
||||||
|
lastCreateCardListPublicId = listPublicId
|
||||||
|
lastCreateCardTitle = title
|
||||||
|
lastCreateCardDescription = description
|
||||||
|
lastCreateCardDueDate = dueDate
|
||||||
|
lastCreateCardTagPublicIds = tagPublicIds
|
||||||
|
return createCardResult
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun moveCard(
|
override suspend fun moveCard(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import kotlinx.coroutines.test.advanceUntilIdle
|
|||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import java.time.LocalDate
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
@@ -49,7 +52,19 @@ class BoardDetailViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun backPressClearsSelectionWhenSelectionActive() = runTest {
|
fun selectAllWithActiveFilter_selectsOnlyVisibleCardsOnCurrentPage() = runTest {
|
||||||
|
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
|
||||||
|
|
||||||
|
viewModel.openFilterDialog()
|
||||||
|
viewModel.updatePendingTagFilterIds(setOf("tag-b"))
|
||||||
|
viewModel.applyFilterDialog()
|
||||||
|
viewModel.selectAllOnCurrentPage()
|
||||||
|
|
||||||
|
assertEquals(setOf("card-2"), viewModel.uiState.value.selectedCardIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun backPressWithSelection_clearsSelectionAndReturnsTrue() = runTest {
|
||||||
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
|
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
|
||||||
|
|
||||||
viewModel.onCardLongPressed("card-1")
|
viewModel.onCardLongPressed("card-1")
|
||||||
@@ -58,6 +73,344 @@ class BoardDetailViewModelTest {
|
|||||||
assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty())
|
assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun openAddCardWhenNoLists_setsCanAddCardFalse_andMessage() = runTest {
|
||||||
|
val viewModel = newLoadedViewModel(
|
||||||
|
this,
|
||||||
|
FakeBoardDetailDataSource(),
|
||||||
|
detailWithoutLists(),
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.openAddCardDialog()
|
||||||
|
|
||||||
|
assertFalse(viewModel.uiState.value.canAddCard)
|
||||||
|
assertEquals("Create a list first to add cards.", viewModel.uiState.value.addCardDisabledMessage)
|
||||||
|
assertFalse(viewModel.uiState.value.isAddCardDialogOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun applyFilters_tagAnyAndQueryAnd_keepsAllLists_filtersCardsOnly() = runTest {
|
||||||
|
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
|
||||||
|
|
||||||
|
viewModel.openFilterDialog()
|
||||||
|
viewModel.updatePendingTagFilterIds(setOf("tag-b"))
|
||||||
|
viewModel.applyFilterDialog()
|
||||||
|
viewModel.openSearchDialog()
|
||||||
|
viewModel.updatePendingTitleQuery("duke")
|
||||||
|
viewModel.applySearchDialog()
|
||||||
|
|
||||||
|
val filtered = viewModel.uiState.value.filteredBoardDetail
|
||||||
|
assertEquals(2, filtered?.lists?.size)
|
||||||
|
assertEquals(listOf("card-2"), filtered?.lists?.get(0)?.cards?.map { it.id })
|
||||||
|
assertTrue(filtered?.lists?.get(1)?.cards?.isEmpty() == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tapActiveTagFilterIcon_clearsTagFilter_withoutOpeningDialog() = runTest {
|
||||||
|
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
|
||||||
|
|
||||||
|
viewModel.openFilterDialog()
|
||||||
|
viewModel.updatePendingTagFilterIds(setOf("tag-a"))
|
||||||
|
viewModel.applyFilterDialog()
|
||||||
|
viewModel.onTagFilterIconTapped()
|
||||||
|
|
||||||
|
assertTrue(viewModel.uiState.value.activeTagFilterIds.isEmpty())
|
||||||
|
assertTrue(viewModel.uiState.value.pendingTagFilterIds.isEmpty())
|
||||||
|
assertFalse(viewModel.uiState.value.isFilterDialogOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancelFilterDialog_restoresPendingFromActive() = runTest {
|
||||||
|
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
|
||||||
|
|
||||||
|
viewModel.openFilterDialog()
|
||||||
|
viewModel.updatePendingTagFilterIds(setOf("tag-a"))
|
||||||
|
viewModel.applyFilterDialog()
|
||||||
|
viewModel.openFilterDialog()
|
||||||
|
viewModel.updatePendingTagFilterIds(setOf("tag-b"))
|
||||||
|
viewModel.cancelFilterDialog()
|
||||||
|
|
||||||
|
assertEquals(setOf("tag-a"), viewModel.uiState.value.activeTagFilterIds)
|
||||||
|
assertEquals(setOf("tag-a"), viewModel.uiState.value.pendingTagFilterIds)
|
||||||
|
assertFalse(viewModel.uiState.value.isFilterDialogOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancelAndSuccess_resetAddDrafts() = runTest {
|
||||||
|
val repository = FakeBoardDetailDataSource(
|
||||||
|
boardDetailResults = ArrayDeque(
|
||||||
|
listOf(
|
||||||
|
BoardsApiResult.Success(detailWithSingleList()),
|
||||||
|
BoardsApiResult.Success(detailWithSingleListAndAppendedList()),
|
||||||
|
BoardsApiResult.Success(detailWithSingleListAndNewTopCard()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
createListResult = BoardsApiResult.Success(CreatedEntityRef("list-new")),
|
||||||
|
createCardResult = BoardsApiResult.Success(CreatedEntityRef("card-new")),
|
||||||
|
)
|
||||||
|
val viewModel = newLoadedViewModel(this, repository)
|
||||||
|
|
||||||
|
viewModel.openAddListDialog()
|
||||||
|
viewModel.updateAddListTitle("Temp list")
|
||||||
|
viewModel.cancelAddListDialog()
|
||||||
|
assertEquals("", viewModel.uiState.value.addListTitleDraft)
|
||||||
|
assertFalse(viewModel.uiState.value.isAddListDialogOpen)
|
||||||
|
|
||||||
|
viewModel.openAddCardDialog()
|
||||||
|
viewModel.updateAddCardTitle("Temp card")
|
||||||
|
viewModel.updateAddCardDescription("temp")
|
||||||
|
viewModel.toggleAddCardTag("tag-a")
|
||||||
|
viewModel.setDueDate(LocalDate.of(2026, 1, 2))
|
||||||
|
viewModel.cancelAddCardDialog()
|
||||||
|
assertEquals("", viewModel.uiState.value.addCardTitleDraft)
|
||||||
|
assertEquals("", viewModel.uiState.value.addCardDescriptionDraft)
|
||||||
|
assertTrue(viewModel.uiState.value.addCardSelectedTagIds.isEmpty())
|
||||||
|
assertNull(viewModel.uiState.value.addCardDueDate)
|
||||||
|
assertFalse(viewModel.uiState.value.isAddCardDialogOpen)
|
||||||
|
|
||||||
|
viewModel.openAddListDialog()
|
||||||
|
viewModel.updateAddListTitle("New list")
|
||||||
|
viewModel.createList()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals("", viewModel.uiState.value.addListTitleDraft)
|
||||||
|
assertFalse(viewModel.uiState.value.isAddListDialogOpen)
|
||||||
|
|
||||||
|
viewModel.openAddCardDialog()
|
||||||
|
viewModel.updateAddCardTitle("New card")
|
||||||
|
viewModel.updateAddCardDescription("desc")
|
||||||
|
viewModel.toggleAddCardTag("tag-a")
|
||||||
|
viewModel.setDueDate(LocalDate.of(2026, 2, 3))
|
||||||
|
viewModel.createCard()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals("", viewModel.uiState.value.addCardTitleDraft)
|
||||||
|
assertEquals("", viewModel.uiState.value.addCardDescriptionDraft)
|
||||||
|
assertTrue(viewModel.uiState.value.addCardSelectedTagIds.isEmpty())
|
||||||
|
assertNull(viewModel.uiState.value.addCardDueDate)
|
||||||
|
assertFalse(viewModel.uiState.value.isAddCardDialogOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createFailure_keepsAddDialogsOpenWithDrafts() = runTest {
|
||||||
|
val repository = FakeBoardDetailDataSource(
|
||||||
|
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
|
||||||
|
createListResult = BoardsApiResult.Failure("List create failed"),
|
||||||
|
createCardResult = BoardsApiResult.Failure("Card create failed"),
|
||||||
|
)
|
||||||
|
val viewModel = newLoadedViewModel(this, repository)
|
||||||
|
|
||||||
|
viewModel.openAddListDialog()
|
||||||
|
viewModel.updateAddListTitle("My list")
|
||||||
|
viewModel.createList()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(viewModel.uiState.value.isAddListDialogOpen)
|
||||||
|
assertEquals("My list", viewModel.uiState.value.addListTitleDraft)
|
||||||
|
|
||||||
|
viewModel.openAddCardDialog()
|
||||||
|
viewModel.updateAddCardTitle("My card")
|
||||||
|
viewModel.updateAddCardDescription("desc")
|
||||||
|
viewModel.toggleAddCardTag("tag-a")
|
||||||
|
viewModel.setDueDate(LocalDate.of(2026, 3, 4))
|
||||||
|
viewModel.createCard()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(viewModel.uiState.value.isAddCardDialogOpen)
|
||||||
|
assertEquals("My card", viewModel.uiState.value.addCardTitleDraft)
|
||||||
|
assertEquals("desc", viewModel.uiState.value.addCardDescriptionDraft)
|
||||||
|
assertEquals(setOf("tag-a"), viewModel.uiState.value.addCardSelectedTagIds)
|
||||||
|
assertEquals(LocalDate.of(2026, 3, 4), viewModel.uiState.value.addCardDueDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createList_blankTitle_setsLocalError_andRejectsBeforeRepositoryCall() = runTest {
|
||||||
|
val repository = FakeBoardDetailDataSource(
|
||||||
|
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
|
||||||
|
)
|
||||||
|
val viewModel = newLoadedViewModel(this, repository)
|
||||||
|
|
||||||
|
viewModel.openAddListDialog()
|
||||||
|
viewModel.updateAddListTitle(" ")
|
||||||
|
viewModel.createList()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(0, repository.createListCalls)
|
||||||
|
assertEquals("List title is required", viewModel.uiState.value.addListTitleError)
|
||||||
|
assertTrue(viewModel.uiState.value.isAddListDialogOpen)
|
||||||
|
val event = withTimeoutOrNull(50) { viewModel.events.first() }
|
||||||
|
assertNull(event)
|
||||||
|
|
||||||
|
viewModel.updateAddListTitle("Valid")
|
||||||
|
assertNull(viewModel.uiState.value.addListTitleError)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createCard_blankTitle_setsLocalError_andRejectsBeforeRepositoryCall() = runTest {
|
||||||
|
val repository = FakeBoardDetailDataSource(
|
||||||
|
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
|
||||||
|
)
|
||||||
|
val viewModel = newLoadedViewModel(this, repository)
|
||||||
|
|
||||||
|
viewModel.openAddCardDialog()
|
||||||
|
viewModel.updateAddCardTitle(" ")
|
||||||
|
viewModel.createCard()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(0, repository.createCardCalls)
|
||||||
|
assertEquals("Card title is required", viewModel.uiState.value.addCardTitleError)
|
||||||
|
assertTrue(viewModel.uiState.value.isAddCardDialogOpen)
|
||||||
|
val event = withTimeoutOrNull(50) { viewModel.events.first() }
|
||||||
|
assertNull(event)
|
||||||
|
|
||||||
|
viewModel.updateAddCardTitle("Valid")
|
||||||
|
assertNull(viewModel.uiState.value.addCardTitleError)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun setDueDate_updatesCardDraftDueDate() = runTest {
|
||||||
|
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithSingleList())
|
||||||
|
|
||||||
|
viewModel.openAddCardDialog()
|
||||||
|
viewModel.setDueDate(LocalDate.of(2026, 4, 5))
|
||||||
|
|
||||||
|
assertEquals(LocalDate.of(2026, 4, 5), viewModel.uiState.value.addCardDueDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearDueDate_resetsCardDraftDueDate() = runTest {
|
||||||
|
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithSingleList())
|
||||||
|
|
||||||
|
viewModel.openAddCardDialog()
|
||||||
|
viewModel.setDueDate(LocalDate.of(2026, 4, 5))
|
||||||
|
viewModel.clearDueDate()
|
||||||
|
|
||||||
|
assertNull(viewModel.uiState.value.addCardDueDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createListPlacementMismatch_emitsExactWarning() = runTest {
|
||||||
|
val repository = FakeBoardDetailDataSource(
|
||||||
|
boardDetailResults = ArrayDeque(
|
||||||
|
listOf(
|
||||||
|
BoardsApiResult.Success(detailWithSingleList()),
|
||||||
|
BoardsApiResult.Success(detailWithListPlacementMismatch()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
createListResult = BoardsApiResult.Success(CreatedEntityRef("list-new")),
|
||||||
|
)
|
||||||
|
val viewModel = newLoadedViewModel(this, repository)
|
||||||
|
val eventDeferred = async { viewModel.events.first() }
|
||||||
|
|
||||||
|
viewModel.openAddListDialog()
|
||||||
|
viewModel.updateAddListTitle("New list")
|
||||||
|
viewModel.createList()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val event = eventDeferred.await()
|
||||||
|
assertTrue(event is BoardDetailUiEvent.ShowWarning)
|
||||||
|
assertEquals(
|
||||||
|
"List created, but server ordering differed. Pull to refresh if needed.",
|
||||||
|
(event as BoardDetailUiEvent.ShowWarning).message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createCardPlacementMismatch_emitsExactWarning() = runTest {
|
||||||
|
val repository = FakeBoardDetailDataSource(
|
||||||
|
boardDetailResults = ArrayDeque(
|
||||||
|
listOf(
|
||||||
|
BoardsApiResult.Success(detailWithSingleList()),
|
||||||
|
BoardsApiResult.Success(detailWithCardPlacementMismatch()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
createCardResult = BoardsApiResult.Success(CreatedEntityRef("card-new")),
|
||||||
|
)
|
||||||
|
val viewModel = newLoadedViewModel(this, repository)
|
||||||
|
val eventDeferred = async { viewModel.events.first() }
|
||||||
|
|
||||||
|
viewModel.openAddCardDialog()
|
||||||
|
viewModel.updateAddCardTitle("New card")
|
||||||
|
viewModel.createCard()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val event = eventDeferred.await()
|
||||||
|
assertTrue(event is BoardDetailUiEvent.ShowWarning)
|
||||||
|
assertEquals(
|
||||||
|
"Card created, but server ordering differed. Pull to refresh if needed.",
|
||||||
|
(event as BoardDetailUiEvent.ShowWarning).message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createFallbackNonDeterministic_emitsExactWarning() = runTest {
|
||||||
|
val repository = FakeBoardDetailDataSource(
|
||||||
|
boardDetailResults = ArrayDeque(
|
||||||
|
listOf(
|
||||||
|
BoardsApiResult.Success(detailWithSingleList()),
|
||||||
|
BoardsApiResult.Success(detailWithSingleListAndAppendedList()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
createListResult = BoardsApiResult.Success(CreatedEntityRef(null)),
|
||||||
|
)
|
||||||
|
val viewModel = newLoadedViewModel(this, repository)
|
||||||
|
val eventDeferred = async { viewModel.events.first() }
|
||||||
|
|
||||||
|
viewModel.openAddListDialog()
|
||||||
|
viewModel.updateAddListTitle("New list")
|
||||||
|
viewModel.createList()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val event = eventDeferred.await()
|
||||||
|
assertTrue(event is BoardDetailUiEvent.ShowWarning)
|
||||||
|
assertEquals(
|
||||||
|
"Created item could not be deterministically verified. Pull to refresh if needed.",
|
||||||
|
(event as BoardDetailUiEvent.ShowWarning).message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createSuccess_refreshFailure_emitsExactWarning() = runTest {
|
||||||
|
val repository = FakeBoardDetailDataSource(
|
||||||
|
boardDetailResults = ArrayDeque(
|
||||||
|
listOf(
|
||||||
|
BoardsApiResult.Success(detailWithSingleList()),
|
||||||
|
BoardsApiResult.Failure("refresh failed"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
createListResult = BoardsApiResult.Success(CreatedEntityRef("list-new")),
|
||||||
|
)
|
||||||
|
val viewModel = newLoadedViewModel(this, repository)
|
||||||
|
val eventDeferred = async { viewModel.events.first() }
|
||||||
|
|
||||||
|
viewModel.openAddListDialog()
|
||||||
|
viewModel.updateAddListTitle("New list")
|
||||||
|
viewModel.createList()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val event = eventDeferred.await()
|
||||||
|
assertTrue(event is BoardDetailUiEvent.ShowWarning)
|
||||||
|
assertEquals(
|
||||||
|
"Changes applied, but refresh failed. Pull to refresh.",
|
||||||
|
(event as BoardDetailUiEvent.ShowWarning).message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun applySearch_withWhitespaceOnlyQuery_trimsToEmptyAndClearsActiveState() = runTest {
|
||||||
|
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
|
||||||
|
|
||||||
|
viewModel.openSearchDialog()
|
||||||
|
viewModel.updatePendingTitleQuery("Duke")
|
||||||
|
viewModel.applySearchDialog()
|
||||||
|
viewModel.openSearchDialog()
|
||||||
|
viewModel.updatePendingTitleQuery(" ")
|
||||||
|
viewModel.applySearchDialog()
|
||||||
|
|
||||||
|
assertEquals("", viewModel.uiState.value.activeTitleQuery)
|
||||||
|
assertEquals("", viewModel.uiState.value.pendingTitleQuery)
|
||||||
|
val filtered = viewModel.uiState.value.filteredBoardDetail
|
||||||
|
assertEquals(2, filtered?.lists?.size)
|
||||||
|
assertEquals(2, filtered?.lists?.get(0)?.cards?.size)
|
||||||
|
assertEquals(1, filtered?.lists?.get(1)?.cards?.size)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun cardTapWithoutSelectionEmitsNavigateEvent() = runTest {
|
fun cardTapWithoutSelectionEmitsNavigateEvent() = runTest {
|
||||||
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
|
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
|
||||||
@@ -506,15 +859,26 @@ class BoardDetailViewModelTest {
|
|||||||
|
|
||||||
private class FakeBoardDetailDataSource(
|
private class FakeBoardDetailDataSource(
|
||||||
val boardDetailResults: ArrayDeque<BoardsApiResult<BoardDetail>> = ArrayDeque(),
|
val boardDetailResults: ArrayDeque<BoardsApiResult<BoardDetail>> = ArrayDeque(),
|
||||||
|
var createListResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("list-created")),
|
||||||
|
var createCardResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("card-created")),
|
||||||
var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
|
var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
|
||||||
var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
|
var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
|
||||||
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit),
|
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit),
|
||||||
var moveGate: CompletableDeferred<Unit>? = null,
|
var moveGate: CompletableDeferred<Unit>? = null,
|
||||||
var renameGate: CompletableDeferred<Unit>? = null,
|
var renameGate: CompletableDeferred<Unit>? = null,
|
||||||
) : BoardDetailDataSource {
|
) : BoardDetailDataSource {
|
||||||
|
var createListCalls: Int = 0
|
||||||
|
var createCardCalls: Int = 0
|
||||||
var moveCalls: Int = 0
|
var moveCalls: Int = 0
|
||||||
var deleteCalls: Int = 0
|
var deleteCalls: Int = 0
|
||||||
var renameCalls: Int = 0
|
var renameCalls: Int = 0
|
||||||
|
var lastCreateListBoardId: String? = null
|
||||||
|
var lastCreateListTitle: String? = null
|
||||||
|
var lastCreateCardListId: String? = null
|
||||||
|
var lastCreateCardTitle: String? = null
|
||||||
|
var lastCreateCardDescription: String? = null
|
||||||
|
var lastCreateCardDueDate: LocalDate? = null
|
||||||
|
var lastCreateCardTagIds: List<String> = emptyList()
|
||||||
var lastRenameListId: String? = null
|
var lastRenameListId: String? = null
|
||||||
var lastRenameTitle: String? = null
|
var lastRenameTitle: String? = null
|
||||||
|
|
||||||
@@ -526,6 +890,29 @@ class BoardDetailViewModelTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
|
||||||
|
createListCalls += 1
|
||||||
|
lastCreateListBoardId = boardPublicId
|
||||||
|
lastCreateListTitle = title
|
||||||
|
return createListResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createCard(
|
||||||
|
listPublicId: String,
|
||||||
|
title: String,
|
||||||
|
description: String?,
|
||||||
|
dueDate: LocalDate?,
|
||||||
|
tagPublicIds: Collection<String>,
|
||||||
|
): BoardsApiResult<CreatedEntityRef> {
|
||||||
|
createCardCalls += 1
|
||||||
|
lastCreateCardListId = listPublicId
|
||||||
|
lastCreateCardTitle = title
|
||||||
|
lastCreateCardDescription = description
|
||||||
|
lastCreateCardDueDate = dueDate
|
||||||
|
lastCreateCardTagIds = tagPublicIds.toList()
|
||||||
|
return createCardResult
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
||||||
moveCalls += 1
|
moveCalls += 1
|
||||||
moveGate?.await()
|
moveGate?.await()
|
||||||
@@ -561,6 +948,14 @@ class BoardDetailViewModelTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun detailWithoutLists(): BoardDetail {
|
||||||
|
return BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun detailWithTwoLists(): BoardDetail {
|
fun detailWithTwoLists(): BoardDetail {
|
||||||
return BoardDetail(
|
return BoardDetail(
|
||||||
id = "board-1",
|
id = "board-1",
|
||||||
@@ -583,6 +978,117 @@ class BoardDetailViewModelTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun detailWithFilterFixture(): BoardDetail {
|
||||||
|
return BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "card-1",
|
||||||
|
title = "Alpha",
|
||||||
|
tags = listOf(BoardTagSummary("tag-a", "A", "#111111")),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "card-2",
|
||||||
|
title = "Duke Nukem",
|
||||||
|
tags = listOf(BoardTagSummary("tag-b", "B", "#222222")),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-2",
|
||||||
|
title = "Doing",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary(
|
||||||
|
id = "card-3",
|
||||||
|
title = "Bravo",
|
||||||
|
tags = listOf(BoardTagSummary("tag-b", "B", "#222222")),
|
||||||
|
dueAtEpochMillis = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detailWithSingleListAndAppendedList(): BoardDetail {
|
||||||
|
return BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(BoardCardSummary("card-1", "Card 1", emptyList(), null)),
|
||||||
|
),
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-new",
|
||||||
|
title = "New list",
|
||||||
|
cards = emptyList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detailWithSingleListAndNewTopCard(): BoardDetail {
|
||||||
|
return BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary("card-new", "New card", emptyList(), null),
|
||||||
|
BoardCardSummary("card-1", "Card 1", emptyList(), null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detailWithListPlacementMismatch(): BoardDetail {
|
||||||
|
return BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-new",
|
||||||
|
title = "New list",
|
||||||
|
cards = emptyList(),
|
||||||
|
),
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(BoardCardSummary("card-1", "Card 1", emptyList(), null)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detailWithCardPlacementMismatch(): BoardDetail {
|
||||||
|
return BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = listOf(
|
||||||
|
BoardListDetail(
|
||||||
|
id = "list-1",
|
||||||
|
title = "To Do",
|
||||||
|
cards = listOf(
|
||||||
|
BoardCardSummary("card-1", "Card 1", emptyList(), null),
|
||||||
|
BoardCardSummary("card-new", "New card", emptyList(), null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun detailWithThreeLists(): BoardDetail {
|
fun detailWithThreeLists(): BoardDetail {
|
||||||
return BoardDetail(
|
return BoardDetail(
|
||||||
id = "board-1",
|
id = "board-1",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 2026-03-16 Board Detail Add/Create/Filter Implementation
|
||||||
|
|
||||||
|
## Execution Verification
|
||||||
|
|
||||||
|
Recorded at: 2026-03-16T15:56:23-04:00
|
||||||
|
|
||||||
|
- `./gradlew test` - PASS
|
||||||
|
- `./gradlew connectedDebugAndroidTest` - PASS
|
||||||
|
- `./gradlew assembleDebug` - PASS
|
||||||
Reference in New Issue
Block a user