Compare commits

..

91 Commits

Author SHA1 Message Date
028c05c0c8 Updated AGENTS.md 2026-03-18 14:54:16 -04:00
8b2f14c470 docs: merge full design spec with settings status 2026-03-18 14:52:55 -04:00
ca005a4de7 fix: key stored api keys by base url 2026-03-18 14:30:04 -04:00
fed1c58ae9 docs: strengthen task 9 handoff evidence 2026-03-18 14:12:29 -04:00
3cc5a3e837 docs: add settings implementation handoff note 2026-03-18 14:07:29 -04:00
4d46c49a6d docs: record task 8 verification evidence 2026-03-18 14:00:56 -04:00
81cfbe070d docs: update settings view implementation status 2026-03-18 13:51:40 -04:00
ed98520de7 fix: clear draft settings on logout 2026-03-18 13:40:28 -04:00
542ec5c181 feat: add drawer logout confirmation and session clear flow 2026-03-18 13:38:19 -04:00
769b893959 fix: make settings dialog recreation-safe 2026-03-18 13:32:20 -04:00
f3349f5dee feat: add in-place settings dialog with immediate apply 2026-03-18 12:21:25 -04:00
8d847ae4ea fix: persist settings api key through api key store 2026-03-18 10:04:22 -04:00
24fccc4d7e feat: implement settings apply coordinator with rollback 2026-03-18 09:57:17 -04:00
41bb01e40c chore: set conservative default api key summary 2026-03-18 09:47:41 -04:00
67fa02525c chore: clarify api key summary wording 2026-03-18 09:45:41 -04:00
b199aa62e5 fix: secure api key preference input and summary handling 2026-03-18 09:42:38 -04:00
03a04b82c5 build: add preference dependency and settings resources 2026-03-18 09:37:05 -04:00
3188fc472a fix: refresh drawer after failed load and use rtl-safe drawer close 2026-03-18 09:30:45 -04:00
eeffb3de49 feat: integrate boards drawer interactions and workspace switching 2026-03-18 09:23:23 -04:00
149663662b refactor: centralize unauthorized detection and simplify workspace rollback 2026-03-18 08:54:57 -04:00
d9d751c461 fix: handle workspace-switch unauthorized and rollback edge case 2026-03-18 08:50:07 -04:00
f27aa6969d feat: add boards drawer profile and workspace state flow 2026-03-18 08:46:22 -04:00
2daad8e7ac fix: harden drawer test assertions and workspace state selectors 2026-03-18 08:26:29 -04:00
964da060ce fix: add selected-state styling hook for workspace drawer rows 2026-03-18 08:20:09 -04:00
717c87122d feat: scaffold boards drawer layout and UI resources 2026-03-18 08:16:27 -04:00
96e971229a docs: update card detail verification status 2026-03-17 00:12:50 -04:00
dd91a62928 test: stabilize board detail intent assertions 2026-03-17 00:02:47 -04:00
f9eeff8dcc docs: refine card detail verification note wording 2026-03-16 23:41:03 -04:00
de9b87d312 docs: record card detail verification outcomes 2026-03-16 23:39:45 -04:00
797da7a1b0 docs: finalize card detail status and newest-first activity behavior 2026-03-16 23:37:42 -04:00
8f2d329368 refactor: remove placeholder card-detail route artifacts 2026-03-16 23:16:45 -04:00
78b34ecef2 feat: route board cards to full card detail screen 2026-03-16 22:51:55 -04:00
344c5a4faa fix: avoid redundant card activity reload and assert ordering 2026-03-16 22:01:09 -04:00
f9625df828 feat: implement card detail activity UI and timeline 2026-03-16 21:48:35 -04:00
a0255c2487 fix: preserve literal markdown on renderer fallback 2026-03-16 21:15:44 -04:00
72e23fded8 feat: add markdown renderer with CommonMark support 2026-03-16 21:12:08 -04:00
1bd540b1cd test: enforce card detail activities order ownership in repository 2026-03-16 21:04:00 -04:00
dfcdc79856 refactor: decouple card detail viewmodel datasource contracts 2026-03-16 21:00:51 -04:00
beab9006a3 fix: preserve pending description flush during in-flight save 2026-03-16 20:55:43 -04:00
aa987c9e00 feat: add card detail viewmodel live-save and debounce state 2026-03-16 20:52:04 -04:00
7132123ccf fix: preserve activity snapshot when comment refresh fails 2026-03-16 20:43:44 -04:00
f85586ddc7 fix: make card comment refresh failures non-fatal 2026-03-16 20:41:07 -04:00
82a3d59105 chore: revert out-of-scope AGENTS update in task 2 2026-03-16 20:36:13 -04:00
d693c42142 feat: add card detail repository with session-aware operations 2026-03-16 20:34:55 -04:00
eee2f9cb17 fix: continue card-detail fallbacks on 2xx incompatibility 2026-03-16 20:29:12 -04:00
70f1558ea3 chore: revert out-of-scope AGENTS update in task 1 2026-03-16 20:25:03 -04:00
fb5d9e1e5b feat: add card detail API contracts and compatibility parsing 2026-03-16 20:22:36 -04:00
334a01fc79 Removed opencode docs. 2026-03-16 19:11:29 -04:00
2c585cde48 fix: respect day-night board detail filter/search icons 2026-03-16 18:59:02 -04:00
81f95c1559 docs: resolve AGENTS merge after board-detail updates 2026-03-16 18:41:19 -04:00
e6f47034bc fix: align select-all behavior and board-detail docs with filters 2026-03-16 16:03:47 -04:00
dc493f5037 docs: improve status readability and record verification outcomes 2026-03-16 15:57:27 -04:00
a63799138b docs: update board detail status for create and filter features 2026-03-16 15:51:16 -04:00
28b81c96a0 chore: revert out-of-scope AGENTS update for task 6 2026-03-16 15:43:22 -04:00
3d4661cbfd test: cover board detail create and filter user flows 2026-03-16 15:38:34 -04:00
1e5979f5c4 fix: align board detail toolbar and add-card dialog scaffolding 2026-03-16 15:02:26 -04:00
3247892038 chore: revert out-of-scope AGENTS update for task 5 2026-03-16 14:54:46 -04:00
b936baf564 feat: add board detail create and filter toolbar UI 2026-03-16 14:47:31 -04:00
de7bb48fe2 test: cover created id fallback keys for create endpoints 2026-03-16 14:17:55 -04:00
c48cd1d525 chore: revert out-of-scope AGENTS update for task 4b 2026-03-16 14:13:39 -04:00
6d313fdf60 test: add create endpoint contract coverage for api client 2026-03-16 14:12:33 -04:00
6a18d6679a fix: separate local create validation from server errors in board detail viewmodel 2026-03-16 14:09:44 -04:00
5e0eff37a6 feat: add board detail create and local filter viewmodel state 2026-03-16 14:04:10 -04:00
8022647047 fix: derive createList append index from board detail 2026-03-16 13:56:45 -04:00
85659f070b test: cover repository create validation guards 2026-03-16 13:51:03 -04:00
7b1c51eae0 feat: add board detail repository create list and card operations 2026-03-16 13:48:35 -04:00
3d8b9e4491 fix: align createCard due-date contract with api client ownership 2026-03-16 13:44:06 -04:00
efe19c794f test: tighten createCard repository delegation normalization coverage 2026-03-16 13:38:11 -04:00
995a6dcae7 feat: add board detail create list and card api calls 2026-03-16 13:34:59 -04:00
80d4c40f10 feat: add board detail create entity reference model 2026-03-16 13:27:11 -04:00
6c67628e40 fix board-detail card move compatibility across API variants 2026-03-16 12:24:35 -04:00
4246d01827 feat: hydrate board label chip colors from API 2026-03-16 03:42:27 -04:00
02b9af0e51 fix: add dark-mode variants for board detail action icons 2026-03-16 03:26:57 -04:00
d9e43a6908 Added opencode dirs to gitignore. 2026-03-16 03:22:03 -04:00
b031dae74b Merge branch 'feature/board-detail-view' 2026-03-16 03:18:48 -04:00
235ab9973c docs: update agent guide with board detail implementation status 2026-03-16 03:15:33 -04:00
81cd654611 fix: finalize board detail action icons and startup guards 2026-03-16 03:15:31 -04:00
e72e584fd4 feat: add board detail batch move and delete workflows 2026-03-16 02:33:55 -04:00
a7af727752 fix: use string resources for card placeholder navigation labels 2026-03-16 01:42:14 -04:00
f5ac01de09 feat: route board and card taps to detail screens 2026-03-16 01:33:48 -04:00
5f5a273d7f feat: implement board detail pager UI and card rendering 2026-03-16 01:19:15 -04:00
4455f0ecd3 test: cover delete flows and guard re-entrant mutations 2026-03-16 00:47:18 -04:00
89537a57b7 feat: add board detail viewmodel state and selection logic 2026-03-16 00:43:48 -04:00
2c40892906 test: add missing board detail repository edge coverage 2026-03-16 00:36:06 -04:00
c56b9d042a feat: implement board detail repository and mutation aggregation 2026-03-16 00:32:55 -04:00
e7ad14902d fix: fail on malformed board detail responses 2026-03-16 00:27:45 -04:00
9602b7959f fix: support plain data board wrapper parsing 2026-03-16 00:25:24 -04:00
a2a54523ef chore: remove out-of-scope changes from task 2 2026-03-16 00:23:04 -04:00
6ea0bd1a2f feat: add board detail and card/list mutation API methods 2026-03-16 00:20:42 -04:00
3cff919222 chore: remove unintended AGENTS.md change from task 1 2026-03-16 00:09:15 -04:00
3af47ba55a feat: define board detail domain and mutation contracts 2026-03-16 00:08:03 -04:00
87 changed files with 16401 additions and 232 deletions

3
.gitignore vendored
View File

@@ -24,3 +24,6 @@ captures/
*.swo *.swo
.kateproject .kateproject
.kateproject.d/ .kateproject.d/
.superpowers/
docs/

257
AGENTS.md
View File

@@ -1,135 +1,164 @@
# AGENTS.md # AGENTS.md
This file provides guidance to AI coding agents when working with code in this repository. This file is for agentic coding assistants working in this repository.
It captures build/test commands and code conventions observed in the codebase.
## Project Overview ## Project Snapshot
Kanbn4Droid is an unofficial app to connect to and manipulate data stored in self-hosted Kan.bn repositories. The app allows the user to authenticate with a Kan.bn server, display a list of available boards, open boards to edit lists and move cards around, and to create and edit cards. The app is written in Kotlin using mostly standard Android libraries and components and is designed to be compiled and edited through the command line, without Android Studio. - Name: `Kanbn4Droid`
- Platform: Android app (Kotlin, XML views, coroutines)
- Build system: Gradle Kotlin DSL (`*.gradle.kts`)
- Module layout: single app module `:app`
- Namespace/application id: `space.hackenslacker.kanbn4droid.app`
- Compile/target SDK: 35
- Min SDK: 29
- Java/Kotlin target: 17
## Dependencies ## Environment Prerequisites
- AndroidX Preferences library. - Java 17 installed and available on PATH
- AndroidX SplashScreen library. - Android SDK command-line tools installed
- Kotlin coroutines (Android dispatcher). - Android SDK packages:
- `platforms;android-35`
- `build-tools;35.0.0`
- `platform-tools`
- If SDK is not auto-detected, copy `local.properties.example` to `local.properties` and set `sdk.dir`
## Current bootstrap status ## Source of Truth for Commands
- Build system: Gradle Kotlin DSL with version catalog (`gradle/libs.versions.toml`). - Prefer Gradle wrapper (`./gradlew`) over system Gradle
- Wrapper: Gradle 8.11.1. - Most Android tasks should be run with explicit module prefix when possible (`:app:<task>`)
- Android plugin and language: AGP 8.9.2, Kotlin 2.1.20, Java 17 target. - Discover tasks with: `./gradlew tasks --all`
- Module layout: single Android app module at `app/`.
- Namespace and application id: `space.hackenslacker.kanbn4droid.app`.
- Minimum SDK: API 29.
- Compile/target SDK: API 35.
- Baseline tests:
- JVM unit tests for auth URL normalization and auth error mapping in `app/src/test/`.
- JVM unit tests for boards repository and boards view model in `app/src/test/space/hackenslacker/kanbn4droid/app/boards/`.
- Instrumentation tests for login and boards flows in `app/src/androidTest/`.
## Command-line workflow ## Build / Lint / Test Commands
- List tasks: `./gradlew tasks` ### Core Daily Commands
- Run unit tests: `./gradlew test`
- Build debug APK: `./gradlew assembleDebug`
- Install debug APK: `./gradlew installDebug`
- Run instrumentation tests: `./gradlew connectedDebugAndroidTest`
`installDebug` and `connectedDebugAndroidTest` require a connected device or emulator. - List tasks: `./gradlew tasks --all`
- Clean: `./gradlew clean`
- Build everything in app module: `./gradlew :app:build`
- Build debug APK: `./gradlew :app:assembleDebug`
- Install debug APK on connected device/emulator: `./gradlew :app:installDebug`
## Architecture ### Lint
**Splash screen** - Run default lint: `./gradlew :app:lint`
- The app displays a standard Android splash screen when open from a cold start. - Run debug lint explicitly: `./gradlew :app:lintDebug`
- Current status: implemented through `Theme.Kanbn4Droid.Splash` with a temporary placeholder image resource at `app/src/main/res/drawable/splash_placeholder.xml`. - Run release lint explicitly: `./gradlew :app:lintRelease`
- Auto-fix safe lint issues: `./gradlew :app:lintFix`
**Login view** ### Unit Tests (JVM)
- It's the first screen the user sees when opening the app if no login has been successfully stored so far.
- The view has fields to request the user for an instance base URL (http or https, with or without port number) and an API key.
- The default base URL is that of the standard Kan.bn instance at https://kan.bn/
- The view tries to check connection with the server at the given base URL using the Kan.bn API healthcheck endpoint.
- The API key is stored in app preferences together with the base URL.
- No migration is performed from prior Credential Manager storage, so users must re-enter their API key one time after upgrading.
- On success, the view stores the URL and API key pair in preferences and moves over to the boards view.
- On successful manual sign-in, the stored workspace id is cleared so the boards flow can resolve a fresh default workspace for the account.
- If there is a URL and API Key pair stored, the view tries to authenticate the user through the API automatically and proceeds to the boards view instantly without showing the login screen if successful.
- If startup authentication fails due to invalid credentials then the stored API key is invalidated; transient connectivity/server failures keep the stored key and return to login.
- Current status: implemented in `MainActivity` with XML views and navigation into `BoardsActivity`.
**Boards list view** - Run all unit tests (all variants): `./gradlew :app:test`
- Displays a list of boards as rounded-square cards with the board's title centered in it. - Run debug unit tests: `./gradlew :app:testDebugUnitTest`
- Clicking on a board card moves on to that board's detail view. - Run release unit tests: `./gradlew :app:testReleaseUnitTest`
- The boards list is refreshed automatically when entering the view.
- The boards list can be refreshed manually with a pull-down gesture on the list.
- The view has a floating + button at the bottom right that shows a modal dialog for creating a new board.
- The modal board creation dialog requests the user for a board name.
- The modal board creation dialog has a toggleable pill button labeled "Use template".
- Enabling the "Use template" button shows a list of available templates to use when creating the board.
- The list of templates is obtained through the Kan.bn API.
- The modal board creation dialog has two buttons at the bottom right for "Cancel" and "Create" respectively.
- Board creation is done through the Kan.bn API.
- On success creating a board moves on to that new board's detal view immediately.
- On failure creating a board shows a modal dialog with the server's reported cause of failure and an OK button.
- Long-pressing an existing board shows a modal dialog asking the user if they want to delete the board, with buttons for "Cancel" and "Delete".
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
- Current status: implemented in `BoardsActivity` using XML Views, `RecyclerView`, `SwipeRefreshLayout`, and a `BoardsViewModel`/`BoardsRepository` flow. The screen includes auto-refresh on entry, pull-to-refresh, create board dialog with optional template selector, and two-step board delete confirmation. Board taps navigate to `BoardDetailPlaceholderActivity` while full board detail is still pending. API calls are workspace-scoped; when no workspace is stored the app resolves workspaces through the API, stores the first workspace id as default, and uses it for board listing, template listing (`type=template`), and board creation.
**Board detail view** ### Run a Single Unit Test (important)
- The board detail view shows the lists in the board as vertical lists. - Single test class:
- Each list has it's title at the top. - `./gradlew :app:testDebugUnitTest --tests "space.hackenslacker.kanbn4droid.app.boards.BoardsViewModelTest"`
- Clicking on the title of a list allows editing the title. - Single test method:
- Below the title each card is shown in a vertically scrolling list of rounded-square shaped cards with one entry for each card in the list. - `./gradlew :app:testDebugUnitTest --tests "space.hackenslacker.kanbn4droid.app.boards.BoardsViewModelTest.createBoardSuccessEmitsNavigateEvent"`
- Each card contains it's title in bold font at the top, a list of the card's tags and it's due date if available, in that order. - Pattern match within class/package:
- The tag list of a card is a series of pill-shaped labels with the tag's name on them and a colored border with the color of the tag. - `./gradlew :app:testDebugUnitTest --tests "space.hackenslacker.kanbn4droid.app.boarddetail.*"`
- The due date of the card is shown in black (or light in dark mode) text if it's still valid, or in red text if it's expired.
- The due date MUST be formatted in the system's locale.
- Swiping right or left on a list allows the user to change to the next or previous list in the board respectively if any.
- On reaching the first or last list in the board it's no longer possible to keep swiping in that direction.
- Long-pressing a card in a list allows selecting that card.
- Tapping other cards in the same or other lists selects them as well.
- Tapping an already selected card deselects it.
- When one or more cards are selected, the top bar of the application MUST display the following buttons using the indicated icon for each one, without text: "Select all" (a 4x4 square grid), "Move cards" (a double-ended left-right arrow), "Delete cards" (a trash can).
- Tapping the "Select all" button selects all cards in the list that is being shown to the user, NOT all cards in all available lists.
- Tapping the "Move cards" button shows a modal dialog that asks the user to what list does he want to move the cards.
- This modal dialog shows a selector with all the lists available in the current board.
- This modal dialog has two buttons at the bottom for "Cancel" and "Move"
- Tapping the "Delete cards" button asks the user for confirmation to delete the cards. This confirmation dialog has two buttons at the bottom for "Cancel" and "Delete".
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
- Long-pressing any of the buttons must show a tooltip with the button name.
**Card detail view** ### Instrumentation Tests (device/emulator required)
- The view shows the card's title in bold letters. Tapping on the card's title allows editing it.
- Below the title a horizontally scrollable there is a list of the card's tags, shown as pills with the border color set to the tag's color obtained from the Kan.bn API.
- Below the tags there is a date field showing the card's due date, if any, or the option to set a due date.
- If the due date is set and is still valid then it is shown in black (or light in dark mode) text.
- If the due date is set and is expired, then it is shown in red text.
- Below the due date the view shows the card's description if any in a markdown-enabled, editable text field.
- Below the description the app shows the lastest 10 elements of the card's edit history (named card activities in the Kan.bn documentation) obtained from the Kan.bn API, in least to most recent order.
- The view has a floating + button that shows a modal dialog that allows adding a comment to the card's history using the Kan.bn API.
- The modal dialog has "Add comment" as a title.
- The modal dialog has an editable markdown-enabled text field for the comment.
- The modal dialog has two buttons at the bottom on the right side for "Cancel" and "Add" respectively.
**Settings view** - Run all connected debug instrumentation tests:
- The view shows a list of settings that can be changed by the user. The following settings are available: - `./gradlew :app:connectedDebugAndroidTest`
- Theme (selector with three choices: Light, Dark, Follow System) - Equivalent aggregate task:
- Base URL (editable text field) - `./gradlew :app:connectedAndroidTest`
- API Key (editable password text field with obfuscated characters)
- Logout (single button that shows a modal dialog asking the user if it's OK to sign out)
- All settings are managed using the AndroidX Preferences library.
- Signing out clears the stored API key and app cache and returns the user to the login screen.
## Considerations ### Run a Single Instrumentation Test (important)
- All views support light or dark theme following the system's indicated preference by default. - Single instrumentation test class:
- The app's default accent color is obtained from the system's theme. - `./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=space.hackenslacker.kanbn4droid.app.BoardsFlowTest`
- The app's design follows Material You guidelines. - Single instrumentation test method:
- Every new feature MUST include a full set of tests, using the standard Android SDK testing framework. - `./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=space.hackenslacker.kanbn4droid.app.BoardsFlowTest#pullToRefreshWorks`
- The minimum supported Android version is Android 10 (API level 29)
- It is preferable to use standard Android libraries and tools whenever possible. ## Repository-Local Rules (Cursor/Copilot)
- If a task would be better served by or can only be done with external libraries then it is allowed to use them but you MUST ask the user for permission to add the library to the project first.
- The documentation for the Kan.bn API is available here https://docs.kan.bn/api-reference/introduction - Checked for Cursor rules in `.cursor/rules/` and `.cursorrules`: none found
- Never commit or push code unless explicitely prompted to do so. - Checked for Copilot instructions in `.github/copilot-instructions.md`: none found
- After every run, update this file as needed to reflect the changes made. - Therefore, no additional repo-local AI instruction files are currently enforced beyond this document
- If those files are added later, treat them as higher-priority guidance and update this AGENTS.md
## Architecture and Code Organization
- UI is Activity-driven (not Compose)
- Feature folders under `app/src/main/java/space/hackenslacker/kanbn4droid/app/`: `auth/`, `boards/`, `boarddetail/`, `carddetail/`
- Typical layering:
- Activity handles view binding and user interaction wiring
- ViewModel holds UI state and events (`StateFlow` + `SharedFlow`)
- Repository/data source handles session and API calls
- API client encapsulates HTTP + response parsing
## Kotlin Style Guidelines (observed conventions)
### Formatting
- Follow Kotlin official code style (`gradle.properties: kotlin.code.style=official`)
- Use 4-space indentation
- Use trailing commas in multiline argument lists/constructors where already used
- Keep functions focused; prefer extraction for repeated logic
- Avoid unnecessary comments; use clear naming instead
### Imports
- Do not use wildcard imports
- Keep imports grouped (platform/library/project), matching existing file style
- Remove unused imports
- Prefer explicit imports for readability in large files
### Types and API design
- Prefer immutable `val`; use `var` only when mutation is required
- Use data classes for immutable UI/domain models
- Use sealed interfaces/classes for event/result hierarchies (`BoardsUiEvent`, mutation results)
- Keep nullability explicit; avoid nullable types unless required by API/domain
- Return domain/result wrappers instead of throwing for expected failures
### Naming
- Packages: lowercase, dot-separated (`space.hackenslacker...`)
- Classes/interfaces/objects: PascalCase
- Functions/properties/locals: camelCase
- Constants: UPPER_SNAKE_CASE (`private const val ...`)
- Test method names: descriptive camelCase phrases that state behavior
### Coroutines and threading
- Use `viewModelScope.launch` for ViewModel async work
- Use `withContext(Dispatchers.IO)` or injected IO dispatcher for blocking/network work
- Keep dispatcher injection points testable (repositories already do this)
- Avoid blocking calls on main thread
### State and events
- Model screen state as immutable `data class` in ViewModel
- Update state via copy semantics (`state.copy(...)`), often with `MutableStateFlow.update`
- Use one-off events via `SharedFlow` rather than state flags for navigation/toasts/dialog alerts
- Keep rendering deterministic from current state
### Error handling
- Prefer explicit result types (`BoardsApiResult`, `AuthResult`) over exceptions for normal failure paths
- Surface user-visible messages through UI events/state
- Validate user input early (blank checks, URL normalization) before network calls
### Android/UI conventions
- Keep user-facing text in `res/values/strings.xml` when practical
- Use Material components already present in the project
- Keep Activity responsibilities focused on binding and orchestration, not business logic
- Respect existing navigation via explicit intents and extras constants
## Testing Conventions
- Unit tests live in `app/src/test/...` and use JUnit4 + coroutines test utilities
- Instrumentation tests live in `app/src/androidTest/...` and use Espresso/Intents
- Prefer fakes/in-memory stores for API/session dependencies in tests
- In coroutine tests:
- set/reset main dispatcher in `@Before/@After`
- use `runTest` and `advanceUntilIdle()`
- Keep tests behavior-focused (arrange -> act -> assert)

190
DESIGN.md Normal file
View File

@@ -0,0 +1,190 @@
# Project Overview
Kanbn4Droid is an unofficial app to connect to and manipulate data stored in self-hosted Kan.bn repositories. The app allows the user to authenticate with a Kan.bn server, display a list of available boards, open boards to edit lists and move cards around, and to create and edit cards. The app is written in Kotlin using mostly standard Android libraries and components and is designed to be compiled and edited through the command line, without Android Studio.
## Dependencies
- AndroidX Preferences library.
- AndroidX SplashScreen library.
- Kotlin coroutines (Android dispatcher).
## Current bootstrap status
- Build system: Gradle Kotlin DSL with version catalog (`gradle/libs.versions.toml`).
- Wrapper: Gradle 8.11.1.
- Android plugin and language: AGP 8.9.2, Kotlin 2.1.20, Java 17 target.
- Module layout: single Android app module at `app/`.
- Namespace and application id: `space.hackenslacker.kanbn4droid.app`.
- Minimum SDK: API 29.
- Compile/target SDK: API 35.
- Baseline tests:
- JVM unit tests for auth URL normalization and auth error mapping in `app/src/test/`.
- JVM unit tests for boards repository and boards view model in `app/src/test/space/hackenslacker/kanbn4droid/app/boards/`.
- Instrumentation tests for login and boards flows in `app/src/androidTest/`.
## Command-line workflow
- List tasks: `./gradlew tasks`
- Run unit tests: `./gradlew test`
- Build debug APK: `./gradlew assembleDebug`
- Install debug APK: `./gradlew installDebug`
- Run instrumentation tests: `./gradlew connectedDebugAndroidTest`
`installDebug` and `connectedDebugAndroidTest` require a connected device or emulator.
## Architecture
**Splash screen**
- The app displays a standard Android splash screen when open from a cold start.
- Current status: implemented through `Theme.Kanbn4Droid.Splash` with a temporary placeholder image resource at `app/src/main/res/drawable/splash_placeholder.xml`.
**Login view**
- It's the first screen the user sees when opening the app if no login has been successfully stored so far.
- The view has fields to request the user for an instance base URL (http or https, with or without port number) and an API key.
- The default base URL is that of the standard Kan.bn instance at https://kan.bn/
- The view tries to check connection with the server at the given base URL using the Kan.bn API healthcheck endpoint.
- The API key is stored in app preferences together with the base URL.
- No migration is performed from prior Credential Manager storage, so users must re-enter their API key one time after upgrading.
- On success, the view stores the URL and API key pair in preferences and moves over to the boards view.
- On successful manual sign-in, the stored workspace id is cleared so the boards flow can resolve a fresh default workspace for the account.
- If there is a URL and API Key pair stored, the view tries to authenticate the user through the API automatically and proceeds to the boards view instantly without showing the login screen if successful.
- If startup authentication fails due to invalid credentials then the stored API key is invalidated; transient connectivity/server failures keep the stored key and return to login.
- Current status: implemented in `MainActivity` with XML views and navigation into `BoardsActivity`.
**Boards list view**
- Displays a list of boards as rounded-square cards with the board's title centered in it.
- Clicking on a board card moves on to that board's detail view.
- The boards list is refreshed automatically when entering the view.
- The boards list can be refreshed manually with a pull-down gesture on the list.
- The view has a floating + button at the bottom right that shows a modal dialog for creating a new board.
- The modal board creation dialog requests the user for a board name.
- The modal board creation dialog has a toggleable pill button labeled "Use template".
- Enabling the "Use template" button shows a list of available templates to use when creating the board.
- The list of templates is obtained through the Kan.bn API.
- The modal board creation dialog has two buttons at the bottom right for "Cancel" and "Create" respectively.
- Board creation is done through the Kan.bn API.
- On success creating a board moves on to that new board's detal view immediately.
- On failure creating a board shows a modal dialog with the server's reported cause of failure and an OK button.
- Long-pressing an existing board shows a modal dialog asking the user if they want to delete the board, with buttons for "Cancel" and "Delete".
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
- Current status: implemented in `BoardsActivity` using XML Views, `RecyclerView`, `SwipeRefreshLayout`, and a `BoardsViewModel`/`BoardsRepository` flow. The screen includes auto-refresh on entry, pull-to-refresh, create board dialog with optional template selector, and two-step board delete confirmation. Board taps navigate to `BoardDetailActivity`. API calls are workspace-scoped; when no workspace is stored the app resolves workspaces through the API, stores the first workspace id as default, and uses it for board listing, template listing (`type=template`), and board creation.
**Board detail view**
- The board detail view shows the lists in the board as vertical lists.
- Each list has it's title at the top.
- Clicking on the title of a list allows editing the title.
- Below the title each card is shown in a vertically scrolling list of rounded-square shaped cards with one entry for each card in the list.
- Each card contains it's title in bold font at the top, a list of the card's tags and it's due date if available, in that order.
- The tag list of a card is a series of pill-shaped labels with the tag's name on them and a colored border with the color of the tag.
- The due date of the card is shown in black (or light in dark mode) text if it's still valid, or in red text if it's expired.
- The due date MUST be formatted in the system's locale.
- Swiping right or left on a list allows the user to change to the next or previous list in the board respectively if any.
- On reaching the first or last list in the board it's no longer possible to keep swiping in that direction.
- Long-pressing a card in a list allows selecting that card.
- Tapping other cards in the same or other lists selects them as well.
- Tapping an already selected card deselects it.
- When one or more cards are selected, the top bar of the application MUST display the following buttons using the indicated icon for each one, without text: "Select all" (a 4x4 square grid), "Move cards" (a double-ended left-right arrow), "Delete cards" (a trash can).
- Tapping the "Select all" button selects all cards in the list that is being shown to the user, NOT all cards in all available lists.
- Tapping the "Move cards" button shows a modal dialog that asks the user to what list does he want to move the cards.
- This modal dialog shows a selector with all the lists available in the current board.
- This modal dialog has two buttons at the bottom for "Cancel" and "Move"
- Tapping the "Delete cards" button asks the user for confirmation to delete the cards. This confirmation dialog has two buttons at the bottom for "Cancel" and "Delete".
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
- Long-pressing any of the buttons must show a tooltip with the button name.
- The view has a floating + button that allows choosing between two options: "Add new list", "Add new card"
- 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 list is created using the Kan.bn API.
- The new list is added at the end of the lists in the current board after the current last list.
- The Add new card dialog shows a modal dialog that works as follows:
- 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 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".
- Applying a filter or search makes the active board show only the cards that match the given criteria (selected tags or matching title).
- The filters are applied locally without contacting the server.
- The search by title filter matches any part of the title. Example: searching for "Duke" matches "Duke Nukem" as well as "Nukem Duke"
- When a filter by tag or search is applied the corresponding button in the title bar gets highlighted.
- 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 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, 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.
- Filter/search toolbar icons use local vector drawables (`ic_filter_list_24`, `ic_search_24`) with day/night variants so dark mode uses light icon fills automatically.
- 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**
- The view shows the card's title in bold letters. Tapping on the card's title allows editing it.
- Below the title a horizontally scrollable there is a list of the card's tags, shown as pills with the border color set to the tag's color obtained from the Kan.bn API.
- Below the tags there is a date field showing the card's due date, if any, or the option to set a due date.
- If the due date is set and is still valid then it is shown in black (or light in dark mode) text.
- If the due date is set and is expired, then it is shown in red text.
- Below the due date the view shows the card's description if any in a markdown-enabled, editable text field.
- Below the description the app shows the latest 10 elements of the card's edit history (named card activities in the Kan.bn documentation) obtained from the Kan.bn API, in newest-first order.
- The view has a floating + button that shows a modal dialog that allows adding a comment to the card's history using the Kan.bn API.
- The modal dialog has "Add comment" as a title.
- The modal dialog has an editable markdown-enabled text field for the comment.
- The modal dialog has two buttons at the bottom on the right side for "Cancel" and "Add" respectively.
- Current status: fully implemented in `CardDetailActivity` with API-backed load and mutation flows through `CardDetailViewModel` and `CardDetailRepository`.
- Title, description, and due date support live-save behavior with field-level saving indicators, inline errors, and retry actions.
- Description supports markdown editing and preview toggle; the add-comment dialog also supports markdown edit/preview and submits through the API, then refreshes timeline data.
- Timeline is rendered as a vertical activity feed with relative timestamps, comment/update action text, markdown-rendered comment bodies, loading/error/empty states, and newest-first ordering.
- Board-detail card taps navigate directly to `CardDetailActivity`; the placeholder card-detail route/activity has been removed.
- Verification note: `./gradlew test`, `./gradlew assembleDebug`, and `./gradlew connectedDebugAndroidTest` are currently passing.
**Settings view**
- The view is available from a side panel that can be shown in the Boards list view by pulling in from the left side of the screen.
- The side panel only occupies up to a third of the screen.
- The view behind the side panel dims when the side panel is open.
- The side panel shows the following content from top to bottom:
- Username as a title in bold text of the currently active user obtained with the "users/me" endpoint of the Kan.bn API.
- Email of the currently active user.
- A button with a gear as icon and the label "Settings"
- Clicking on the "Settings" button shows the settings view as described ahead.
- The word "Workspaces" as a smaller title.
- A vertically scrollable list of workspace names available on the server.
- The currently active workspace is highlighted
- Clicking on a different workspace sets that as the active workspace and reloads the Boards list view to refresh the available boards.
- At the bottom of the side panel there is a logout button that shows a modal dialog asking the user if it's OK to sign out.
- Signing out clears the stored API key and app cache and returns the user to the login screen.
- The settings view shows a list of settings that can be changed by the user. The following settings are available:
- Theme (selector with three choices: Light, Dark, Follow System)
- Base URL (editable text field)
- API Key (editable password text field with obfuscated characters)
- All settings are managed using the AndroidX Preferences library.
- Changing any settings makes it apply instantly when leaving the settings view without logging out.
- Current status: implemented through a left-side drawer in `BoardsActivity` plus an in-place settings dialog (`SettingsDialogFragment` + `SettingsPreferencesFragment`) using AndroidX Preferences.
- Drawer behavior is implemented with `DrawerLayout`: left-edge gesture + toolbar open action, dimmed background, and runtime width capped to one-third of screen width.
- Drawer content is implemented with profile header (`users/me`), workspaces list with active highlight, settings entry, retry/error states, and logout action with confirmation.
- Workspace switching is implemented with active-workspace persistence and boards refresh; switch failures restore previous selection and unauthorized responses force sign-out.
- Settings dialog implements Theme/Base URL/API key drafts, save-and-close apply flow, immediate theme application, credential re-auth on URL/key changes, and safe rollback on apply failure.
- Logout clears session/auth/workspace state and returns to login.
- Verification note: `./gradlew :app:testDebugUnitTest` and `./gradlew :app:assembleDebug` are passing locally; `./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=space.hackenslacker.kanbn4droid.app.BoardsFlowTest` is currently blocked in this environment due to no connected device.
## Considerations
- All views support light or dark theme following the system's indicated preference by default.
- The app's default accent color is obtained from the system's theme.
- The app's design follows Material You guidelines.
- Every new feature MUST include a full set of tests, using the standard Android SDK testing framework.
- The minimum supported Android version is Android 10 (API level 29)
- It is preferable to use standard Android libraries and tools whenever possible.
- If a task would be better served by or can only be done with external libraries then it is allowed to use them but you MUST ask the user for permission to add the library to the project first.
- The documentation for the Kan.bn API is available here https://docs.kan.bn/api-reference/introduction
- Never push code unless explicitely prompted to do so.
- After every run, update this file as needed to reflect the changes made.

View File

@@ -53,7 +53,9 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.recyclerview) implementation(libs.androidx.recyclerview)
implementation(libs.androidx.swiperefreshlayout) implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.preference)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.commonmark)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)

View File

@@ -2,6 +2,13 @@ package space.hackenslacker.kanbn4droid.app
import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.ViewAction
import androidx.test.espresso.contrib.DrawerActions
import androidx.test.espresso.action.CoordinatesProvider
import androidx.test.espresso.action.GeneralSwipeAction
import androidx.test.espresso.action.Press
import androidx.test.espresso.action.Swipe
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
@@ -12,22 +19,40 @@ 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
import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CompletableDeferred
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.AuthResult import space.hackenslacker.kanbn4droid.app.auth.AuthResult
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.SettingsApplyCoordinator
import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyResult
import space.hackenslacker.kanbn4droid.app.auth.SessionStore import space.hackenslacker.kanbn4droid.app.auth.SessionStore
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
import space.hackenslacker.kanbn4droid.app.boards.DrawerProfile
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary
import space.hackenslacker.kanbn4droid.app.settings.SettingsDialogFragment
import java.util.ArrayDeque
import android.view.View
import android.widget.TextView
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class BoardsFlowTest { class BoardsFlowTest {
@@ -46,7 +71,7 @@ class BoardsFlowTest {
} }
@Test @Test
fun boardTapNavigatesToDetailPlaceholderWithExtras() { fun boardTapNavigatesToBoardDetailActivity() {
MainActivity.dependencies.apiClientFactory = { MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient( FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")), boards = mutableListOf(BoardSummary("1", "Alpha")),
@@ -58,9 +83,9 @@ class BoardsFlowTest {
onView(withText("Alpha")).perform(click()) onView(withText("Alpha")).perform(click())
Intents.intended(hasComponent(BoardDetailPlaceholderActivity::class.java.name)) Intents.intended(hasComponent(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity::class.java.name))
Intents.intended(hasExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_ID, "1")) Intents.intended(hasExtra(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity.EXTRA_BOARD_ID, "1"))
Intents.intended(hasExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_TITLE, "Alpha")) Intents.intended(hasExtra(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity.EXTRA_BOARD_TITLE, "Alpha"))
} }
@Test @Test
@@ -79,8 +104,8 @@ class BoardsFlowTest {
onView(withId(R.id.useTemplateChip)).perform(click()) onView(withId(R.id.useTemplateChip)).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click()) onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
Intents.intended(hasComponent(BoardDetailPlaceholderActivity::class.java.name)) Intents.intended(hasComponent(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity::class.java.name))
Intents.intended(hasExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_TITLE, "Roadmap")) Intents.intended(hasExtra(space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity.EXTRA_BOARD_TITLE, "Roadmap"))
} }
@Test @Test
@@ -116,9 +141,462 @@ class BoardsFlowTest {
onView(withText("Alpha")).check(matches(isDisplayed())) onView(withText("Alpha")).check(matches(isDisplayed()))
} }
@Test
fun drawerOpensAndShowsWorkspaceSection() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withId(R.id.drawerWorkspacesTitle)).check(matches(isDisplayed()))
onView(withId(R.id.drawerWorkspacesTitle)).check(matches(withText(R.string.drawer_workspaces)))
onView(withId(R.id.drawerWorkspacesRecyclerView)).check(matches(isDisplayed()))
onView(withId(R.id.drawerLogoutButton)).check(matches(isDisplayed()))
}
@Test
fun workspaceSelectionHighlightsAndRefreshesBoards() {
val fake = MultiWorkspaceFakeBoardsApiClient(
boardsByWorkspace = mapOf(
"ws-1" to listOf(BoardSummary("1", "Alpha")),
"ws-2" to listOf(BoardSummary("2", "Beta")),
),
)
MainActivity.dependencies.apiClientFactory = { fake }
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1") }
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withText("Platform")).perform(click())
onView(withText("Beta")).check(matches(isDisplayed()))
onView(withText("Alpha")).check(doesNotExist())
scenario.onActivity { activity ->
val recycler = activity.findViewById<RecyclerView>(R.id.drawerWorkspacesRecyclerView)
val hasActivatedPlatform = (0 until recycler.childCount)
.map { recycler.getChildAt(it) }
.any { row ->
val title = row.findViewById<TextView>(R.id.workspaceTitleText).text.toString()
title == "Platform" && row.isActivated
}
assertTrue(hasActivatedPlatform)
}
assertTrue(fake.listBoardsWorkspaceCalls.contains("ws-2"))
}
@Test
fun drawerOpensFromLeftEdgeGesture() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(swipeFromLeftEdge())
scenario.onActivity { activity ->
val drawer = activity.findViewById<DrawerLayout>(R.id.boardsDrawerLayout)
assertTrue(drawer.isDrawerOpen(GravityCompat.START))
}
}
@Test
fun drawerRetryButtonReloadsAfterRecoverableFailure() {
val fake = MultiWorkspaceFakeBoardsApiClient(
boardsByWorkspace = mapOf("ws-1" to listOf(BoardSummary("1", "Alpha"))),
usersMeResponses = ArrayDeque(
listOf(
BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."),
BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com")),
),
),
workspaceResponses = ArrayDeque(
listOf(
BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."),
BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"), WorkspaceSummary("ws-2", "Platform"))),
),
),
)
MainActivity.dependencies.apiClientFactory = { fake }
ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withId(R.id.drawerRetryButton)).check(matches(isDisplayed())).perform(click())
onView(withText("Main")).check(matches(isDisplayed()))
assertTrue(fake.listWorkspacesCalls >= 2)
}
@Test
fun drawerUnauthorizedForcesSignOutToLogin() {
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val fake = MultiWorkspaceFakeBoardsApiClient(
boardsByWorkspace = mapOf("ws-1" to listOf(BoardSummary("1", "Alpha"))),
usersMeResponses = ArrayDeque(listOf(BoardsApiResult.Failure("Server error: 401"))),
workspaceResponses = ArrayDeque(listOf(BoardsApiResult.Failure("Server error: 401"))),
)
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
MainActivity.dependencies.apiClientFactory = { fake }
ActivityScenario.launch(BoardsActivity::class.java)
Intents.intended(hasComponent(MainActivity::class.java.name))
assertEquals(null, sessionStore.getWorkspaceId())
}
@Test
fun workspaceSelectionClosesDrawerAfterSuccess() {
val fake = MultiWorkspaceFakeBoardsApiClient(
boardsByWorkspace = mapOf(
"ws-1" to listOf(BoardSummary("1", "Alpha")),
"ws-2" to listOf(BoardSummary("2", "Beta")),
),
)
MainActivity.dependencies.apiClientFactory = { fake }
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1") }
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withText("Platform")).perform(click())
onView(withText("Beta")).check(matches(isDisplayed()))
scenario.onActivity { activity ->
val drawer = activity.findViewById<DrawerLayout>(R.id.boardsDrawerLayout)
assertFalse(drawer.isDrawerOpen(GravityCompat.START))
}
}
@Test
fun drawerWidthNeverExceedsOneThirdOfScreen() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
scenario.onActivity { activity ->
val drawerContent = activity.findViewById<View>(R.id.boardsDrawerContent)
val displayWidthPx = activity.resources.displayMetrics.widthPixels
assertTrue(drawerContent.layoutParams.width <= displayWidthPx / 3)
}
}
@Test
fun logoutConfirmationClearsSessionAndReturnsToLogin() {
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val apiKeyStore = InMemoryApiKeyStore("api")
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
MainActivity.dependencies.apiKeyStoreFactory = { apiKeyStore }
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withId(R.id.drawerLogoutButton)).perform(click())
onView(withText(R.string.drawer_logout)).inRoot(isDialog()).perform(click())
Intents.intended(hasComponent(MainActivity::class.java.name))
assertEquals(null, sessionStore.getBaseUrl())
assertEquals(null, sessionStore.getWorkspaceId())
assertEquals(null, sessionStore.getDraftBaseUrl())
assertEquals(null, sessionStore.getDraftApiKey())
assertEquals(listOf("https://kan.bn/"), apiKeyStore.invalidatedBaseUrls)
scenario.onActivity { activity ->
assertTrue(activity.isFinishing)
}
}
@Test
fun logoutConfirmationCancelKeepsUserOnBoards() {
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val apiKeyStore = InMemoryApiKeyStore("api")
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
MainActivity.dependencies.apiKeyStoreFactory = { apiKeyStore }
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withId(R.id.drawerLogoutButton)).perform(click())
onView(withText(R.string.cancel)).inRoot(isDialog()).perform(click())
onView(withText("Alpha")).check(matches(isDisplayed()))
assertEquals("https://kan.bn/", sessionStore.getBaseUrl())
assertEquals("ws-1", sessionStore.getWorkspaceId())
assertTrue(apiKeyStore.invalidatedBaseUrls.isEmpty())
scenario.onActivity { activity ->
assertFalse(activity.isFinishing)
}
}
@Test
fun settingsDialogOpensFromDrawerAndShowsPreferences() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
onView(withText(R.string.settings_theme_title)).check(matches(isDisplayed()))
onView(withText(R.string.settings_base_url_title)).check(matches(isDisplayed()))
onView(withText(R.string.settings_api_key_title)).check(matches(isDisplayed()))
onView(withId(R.id.settingsSaveAndCloseButton)).check(matches(isDisplayed()))
}
@Test
fun settingsDialogBlocksBackAndOutsideDismiss() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
pressBack()
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
onView(isRoot()).perform(clickTopLeftCorner())
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
}
@Test
fun settingsButtonClosesDrawerBeforeOpeningDialog() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
scenario.onActivity { activity ->
val drawer = activity.findViewById<DrawerLayout>(R.id.boardsDrawerLayout)
assertFalse(drawer.isDrawerOpen(GravityCompat.START))
}
}
@Test
fun settingsDialogDisablesControlsWhileApplyInProgress() {
val blocked = CompletableDeferred<Unit>()
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
MainActivity.dependencies.settingsApplyCoordinatorFactory = { _, sessionStore, apiClient, apiKeyStore ->
object : SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore) {
override suspend fun apply(): SettingsApplyResult {
blocked.await()
return SettingsApplyResult.SuccessNoCredentialChange(themeChanged = false)
}
}
}
ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
onView(withId(R.id.settingsSaveAndCloseButton)).perform(click())
onView(withId(R.id.settingsApplyProgress)).check(matches(isDisplayed()))
onView(withId(R.id.settingsSaveAndCloseButton)).check(matches(org.hamcrest.Matchers.not(isEnabled())))
blocked.complete(Unit)
}
@Test
fun settingsDialogClosesOnlyOnSuccessfulApply() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
MainActivity.dependencies.settingsApplyCoordinatorFactory = { _, sessionStore, apiClient, apiKeyStore ->
object : SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore) {
override suspend fun apply(): SettingsApplyResult {
return SettingsApplyResult.ValidationError(
field = "apiKey",
message = "API key is required",
)
}
}
}
ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
onView(withId(R.id.settingsSaveAndCloseButton)).perform(click())
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
onView(withId(R.id.settingsErrorText)).check(matches(withText("API key is required")))
}
@Test
fun settingsSaveSuccessReauthsAndRefreshesBoards() {
val fake = QueueBoardsApiClient()
fake.enqueueBoards(BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha"))))
fake.enqueueBoards(BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha"))))
fake.enqueueBoards(BoardsApiResult.Success(listOf(BoardSummary("2", "Beta"))))
MainActivity.dependencies.apiClientFactory = { fake }
MainActivity.dependencies.settingsApplyCoordinatorFactory = { _, sessionStore, apiClient, apiKeyStore ->
object : SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore) {
override suspend fun apply(): SettingsApplyResult = SettingsApplyResult.SuccessCredentialChange
}
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
waitForIdle()
val baselineBoardsCalls = fake.listBoardsCalls
val baselineMeCalls = fake.getCurrentUserCalls
val baselineWorkspacesCalls = fake.listWorkspacesCalls
openSettingsFromDrawer()
onView(withId(R.id.settingsSaveAndCloseButton)).perform(click())
onView(withText("Beta")).check(matches(isDisplayed()))
assertTrue(fake.listBoardsCalls > baselineBoardsCalls)
assertTrue(fake.getCurrentUserCalls > baselineMeCalls)
assertTrue(fake.listWorkspacesCalls > baselineWorkspacesCalls)
scenario.onActivity {
assertTrue(it.supportFragmentManager.findFragmentByTag(SettingsDialogFragment.TAG) == null)
}
}
@Test
fun settingsSaveFailureRollsBackAndStaysOnBoards() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
MainActivity.dependencies.settingsApplyCoordinatorFactory = { _, sessionStore, apiClient, apiKeyStore ->
object : SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore) {
override suspend fun apply(): SettingsApplyResult {
return SettingsApplyResult.AuthError("Authentication failed. Check your API key.")
}
}
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
onView(withId(R.id.settingsSaveAndCloseButton)).perform(click())
onView(withText("Alpha")).check(matches(isDisplayed()))
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
scenario.onActivity { activity ->
assertFalse(activity.isFinishing)
}
}
@Test
fun settingsApplySuccessThenRefreshFailureShowsRetryableBoardsError() {
val fake = QueueBoardsApiClient()
fake.enqueueBoards(BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha"))))
fake.enqueueBoards(BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha"))))
fake.enqueueBoards(BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."))
MainActivity.dependencies.apiClientFactory = { fake }
MainActivity.dependencies.settingsApplyCoordinatorFactory = { _, sessionStore, apiClient, apiKeyStore ->
object : SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore) {
override suspend fun apply(): SettingsApplyResult = SettingsApplyResult.SuccessCredentialChange
}
}
ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
onView(withId(R.id.settingsSaveAndCloseButton)).perform(click())
onView(withText("Cannot reach server. Check your connection and URL."))
.inRoot(isDialog())
.check(matches(isDisplayed()))
}
@Test
fun settingsDialogSurvivesActivityRecreate() {
MainActivity.dependencies.apiClientFactory = {
FakeBoardsApiClient(
boards = mutableListOf(BoardSummary("1", "Alpha")),
templates = emptyList(),
)
}
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
openSettingsFromDrawer()
scenario.recreate()
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
onView(withId(R.id.settingsSaveAndCloseButton)).check(matches(isDisplayed()))
}
private fun openSettingsFromDrawer() {
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
onView(withId(R.id.drawerSettingsButton)).perform(click())
onView(withId(R.id.settingsFragmentContainer)).check(matches(isDisplayed()))
}
private fun clickTopLeftCorner(): ViewAction {
val start = CoordinatesProvider { floatArrayOf(2f, 2f) }
val end = CoordinatesProvider { floatArrayOf(2f, 2f) }
return GeneralSwipeAction(Swipe.FAST, start, end, Press.FINGER)
}
private fun waitForIdle() {
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
}
private fun swipeFromLeftEdge(): ViewAction {
val start = CoordinatesProvider { view -> floatArrayOf(5f, view.height * 0.5f) }
val end = CoordinatesProvider { view -> floatArrayOf(view.width * 0.6f, view.height * 0.5f) }
return GeneralSwipeAction(Swipe.FAST, start, end, Press.FINGER)
}
private class InMemorySessionStore( private class InMemorySessionStore(
private var baseUrl: String? = null, private var baseUrl: String? = null,
private var workspaceId: String? = "ws-1",
) : SessionStore { ) : SessionStore {
private var draftBaseUrl: String? = baseUrl
private var draftApiKey: String? = null
override fun getBaseUrl(): String? = baseUrl override fun getBaseUrl(): String? = baseUrl
override fun saveBaseUrl(url: String) { override fun saveBaseUrl(url: String) {
@@ -129,18 +607,38 @@ class BoardsFlowTest {
baseUrl = null baseUrl = null
} }
override fun getWorkspaceId(): String? = "ws-1" override fun getWorkspaceId(): String? = workspaceId
override fun saveWorkspaceId(workspaceId: String) { override fun saveWorkspaceId(workspaceId: String) {
this.workspaceId = workspaceId
} }
override fun clearWorkspaceId() { override fun clearWorkspaceId() {
workspaceId = null
}
override fun initializeDraftsFromCommitted() {
draftBaseUrl = baseUrl
}
override fun getDraftBaseUrl(): String? = draftBaseUrl
override fun saveDraftBaseUrl(url: String) {
draftBaseUrl = url
}
override fun getDraftApiKey(): String? = draftApiKey
override fun saveDraftApiKey(apiKey: String) {
draftApiKey = apiKey
} }
} }
private class InMemoryApiKeyStore( private class InMemoryApiKeyStore(
private var key: String?, private var key: String?,
) : ApiKeyStore { ) : ApiKeyStore {
val invalidatedBaseUrls = mutableListOf<String>()
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> { override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
key = apiKey key = apiKey
return Result.success(Unit) return Result.success(Unit)
@@ -149,6 +647,7 @@ class BoardsFlowTest {
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(key) override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(key)
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> { override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
invalidatedBaseUrls += baseUrl
key = null key = null
return Result.success(Unit) return Result.success(Unit)
} }
@@ -157,14 +656,23 @@ class BoardsFlowTest {
private class FakeBoardsApiClient( private class FakeBoardsApiClient(
private val boards: MutableList<BoardSummary>, private val boards: MutableList<BoardSummary>,
private val templates: List<BoardTemplate>, private val templates: List<BoardTemplate>,
private val profile: DrawerProfile = DrawerProfile("Alice", "alice@example.com"),
private val workspaces: List<WorkspaceSummary> = listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
) : KanbnApiClient { ) : KanbnApiClient {
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
return BoardsApiResult.Success(profile)
}
override suspend fun listWorkspaces( override suspend fun listWorkspaces(
baseUrl: String, baseUrl: String,
apiKey: String, apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> { ): BoardsApiResult<List<WorkspaceSummary>> {
return BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))) return BoardsApiResult.Success(workspaces)
} }
override suspend fun listBoards( override suspend fun listBoards(
@@ -199,5 +707,168 @@ class BoardsFlowTest {
boards.removeAll { it.id == boardId } boards.removeAll { it.id == boardId }
return BoardsApiResult.Success(Unit) return BoardsApiResult.Success(Unit)
} }
override suspend fun getLabelByPublicId(
baseUrl: String,
apiKey: String,
labelId: String,
): BoardsApiResult<LabelDetail> {
return BoardsApiResult.Failure("Not needed in boards flow tests")
}
}
private class MultiWorkspaceFakeBoardsApiClient(
private val boardsByWorkspace: Map<String, List<BoardSummary>>,
private val usersMeResponses: ArrayDeque<BoardsApiResult<DrawerProfile>> = ArrayDeque(
listOf(BoardsApiResult.Success(DrawerProfile("Alice", "alice@example.com"))),
),
private val workspaceResponses: ArrayDeque<BoardsApiResult<List<WorkspaceSummary>>> = ArrayDeque(
listOf(
BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
),
),
),
) : KanbnApiClient {
var listWorkspacesCalls: Int = 0
val listBoardsWorkspaceCalls = mutableListOf<String>()
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
return if (usersMeResponses.isEmpty()) {
BoardsApiResult.Success(DrawerProfile("Alice", "alice@example.com"))
} else {
usersMeResponses.removeFirst()
}
}
override suspend fun listWorkspaces(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> {
listWorkspacesCalls += 1
return if (workspaceResponses.isEmpty()) {
BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
)
} else {
workspaceResponses.removeFirst()
}
}
override suspend fun listBoards(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardSummary>> {
listBoardsWorkspaceCalls += workspaceId
return BoardsApiResult.Success(boardsByWorkspace[workspaceId].orEmpty())
}
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> {
return BoardsApiResult.Success(emptyList())
}
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
workspaceId: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
return BoardsApiResult.Success(BoardSummary("new", name))
}
override suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Success(Unit)
}
override suspend fun getLabelByPublicId(
baseUrl: String,
apiKey: String,
labelId: String,
): BoardsApiResult<LabelDetail> {
return BoardsApiResult.Failure("Not needed in boards flow tests")
}
}
private class QueueBoardsApiClient : KanbnApiClient {
private val boardsResponses: ArrayDeque<BoardsApiResult<List<BoardSummary>>> = ArrayDeque()
var listBoardsCalls: Int = 0
var getCurrentUserCalls: Int = 0
var listWorkspacesCalls: Int = 0
fun enqueueBoards(result: BoardsApiResult<List<BoardSummary>>) {
boardsResponses.addLast(result)
}
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
getCurrentUserCalls += 1
return BoardsApiResult.Success(DrawerProfile("Alice", "alice@example.com"))
}
override suspend fun listWorkspaces(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> {
listWorkspacesCalls += 1
return BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
}
override suspend fun listBoards(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardSummary>> {
listBoardsCalls += 1
return if (boardsResponses.isEmpty()) {
BoardsApiResult.Success(emptyList())
} else {
boardsResponses.removeFirst()
}
}
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> {
return BoardsApiResult.Success(emptyList())
}
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
workspaceId: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
return BoardsApiResult.Success(BoardSummary("new", name))
}
override suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Success(Unit)
}
override suspend fun getLabelByPublicId(
baseUrl: String,
apiKey: String,
labelId: String,
): BoardsApiResult<LabelDetail> {
return BoardsApiResult.Failure("Not needed in boards flow tests")
}
} }
} }

View File

@@ -0,0 +1,400 @@
package space.hackenslacker.kanbn4droid.app
import android.content.Intent
import android.content.ComponentName
import android.content.pm.PackageManager
import android.graphics.Color
import android.view.View
import android.widget.TextView
import android.view.inputmethod.EditorInfo
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.android.material.color.MaterialColors
import java.time.LocalDate
import java.util.ArrayDeque
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CompletableDeferred
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.not
import org.hamcrest.TypeSafeMatcher
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import space.hackenslacker.kanbn4droid.app.carddetail.CardActivity
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetail
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailActivity
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailDataSource
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailTag
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailUiEvent
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailUiState
import space.hackenslacker.kanbn4droid.app.carddetail.DataSourceResult
@RunWith(AndroidJUnit4::class)
class CardDetailFlowTest {
private lateinit var fakeDataSource: FakeCardDetailDataSource
private val observedStates = mutableListOf<CardDetailUiState>()
private val observedEvents = mutableListOf<CardDetailUiEvent>()
@Before
fun setUp() {
observedStates.clear()
observedEvents.clear()
Intents.init()
MainActivity.dependencies.clear()
fakeDataSource = FakeCardDetailDataSource()
CardDetailActivity.testDataSourceFactory = { fakeDataSource }
CardDetailActivity.testUiStateObserver = { observedStates += it }
CardDetailActivity.testEventObserver = { observedEvents += it }
}
@After
fun tearDown() {
Intents.release()
MainActivity.dependencies.clear()
CardDetailActivity.testDataSourceFactory = null
CardDetailActivity.testUiStateObserver = null
CardDetailActivity.testEventObserver = null
}
@Test
fun missingCardIdShowsBlockingDialogAndFinishes() {
val scenario = launchCardDetail(cardId = null)
onView(withText(R.string.card_detail_unable_to_open_card)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.ok)).inRoot(isDialog()).perform(click())
scenario.onActivity { assertTrue(it.isFinishing) }
}
@Test
fun loadingContentAndErrorSectionsRenderDeterministically() {
val gate = CompletableDeferred<Unit>()
fakeDataSource.loadGate = gate
val scenario = launchCardDetail(waitForContent = false)
onView(withId(R.id.cardDetailInitialProgress)).check(matches(isDisplayed()))
gate.complete(Unit)
scenario.onActivity { }
onView(withId(R.id.cardDetailContentScroll)).check(matches(isDisplayed()))
fakeDataSource.loadResults.add(DataSourceResult.GenericError("Load failed"))
scenario.onActivity { it.recreate() }
onView(withText("Load failed")).check(matches(isDisplayed()))
}
@Test
fun titleLiveSaveErrorAndRetryFlow() {
fakeDataSource.updateTitleResults.add(DataSourceResult.GenericError("Title rejected"))
fakeDataSource.updateTitleResults.add(DataSourceResult.Success(Unit))
val scenario = launchCardDetail()
onView(withId(R.id.cardDetailTitleInput)).perform(replaceText("Renamed"), closeSoftKeyboard())
scenario.onActivity {
val input = it.findViewById<TextView>(R.id.cardDetailTitleInput)
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
}
awaitCondition { fakeDataSource.updateTitleCalls.get() == 1 }
onView(withText("Title rejected")).check(matches(isDisplayed()))
onView(withId(R.id.cardDetailTitleRetryButton)).perform(click())
awaitCondition { fakeDataSource.updateTitleCalls.get() == 2 }
}
@Test
fun dueDateSetClearAndExpiredStyling() {
fakeDataSource.currentCard = fakeDataSource.currentCard.copy(dueDate = LocalDate.now().minusDays(1))
var scenario = launchCardDetail()
var expectedErrorColor = Color.RED
scenario.onActivity {
expectedErrorColor = MaterialColors.getColor(
it.findViewById(android.R.id.content),
com.google.android.material.R.attr.colorError,
Color.RED,
)
}
onView(withId(R.id.cardDetailDueDateText)).check(matches(withCurrentTextColor(expectedErrorColor)))
scenario.close()
fakeDataSource.currentCard = fakeDataSource.currentCard.copy(dueDate = null)
scenario = launchCardDetail()
onView(withId(R.id.cardDetailDueDateText)).perform(click())
onView(withText(android.R.string.ok)).inRoot(isDialog()).perform(click())
awaitCondition { fakeDataSource.updateDueDateCalls.get() == 1 }
onView(withId(R.id.cardDetailDueDateClearButton)).perform(click())
awaitCondition { fakeDataSource.updateDueDateCalls.get() == 2 }
assertNull(fakeDataSource.lastDueDate)
}
@Test
fun descriptionLiveSaveRetryAndEditPreviewToggle() {
fakeDataSource.updateDescriptionResults.add(DataSourceResult.GenericError("Desc failed"))
fakeDataSource.updateDescriptionResults.add(DataSourceResult.Success(Unit))
val scenario = launchCardDetail()
onView(withId(R.id.cardDetailDescriptionInput)).perform(replaceText("**hello**"), closeSoftKeyboard())
scenario.onActivity { it.findViewById<View>(R.id.cardDetailDescriptionInput).clearFocus() }
awaitCondition { fakeDataSource.updateDescriptionCalls.get() == 1 }
onView(withText("Desc failed")).check(matches(isDisplayed()))
onView(withId(R.id.cardDetailDescriptionRetryButton)).perform(click())
awaitCondition { fakeDataSource.updateDescriptionCalls.get() == 2 }
onView(withId(R.id.cardDetailDescriptionPreviewButton)).perform(click())
onView(withId(R.id.cardDetailDescriptionPreviewText)).check(matches(isDisplayed()))
onView(withId(R.id.cardDetailDescriptionEditButton)).perform(click())
onView(withId(R.id.cardDetailDescriptionInputLayout)).check(matches(isDisplayed()))
}
@Test
fun addCommentDialogSupportsEditPreviewAndAddCancel() {
launchCardDetail()
onView(withId(R.id.cardDetailAddCommentFab)).perform(click())
onView(withId(android.R.id.button2)).check(matches(isDisplayed()))
onView(withId(android.R.id.button2)).perform(click())
onView(withId(R.id.cardDetailAddCommentFab)).perform(click())
onView(withId(R.id.commentDialogInput)).check(matches(isDisplayed()))
onView(withId(R.id.commentDialogInput)).perform(replaceText("new comment"), closeSoftKeyboard())
onView(withId(R.id.commentDialogPreviewButton)).perform(click())
onView(withId(R.id.commentDialogPreviewText)).check(matches(isDisplayed()))
onView(withText(R.string.add)).perform(click())
awaitCondition {
fakeDataSource.addCommentCalls.get() == 1
}
assertEquals(1, fakeDataSource.listActivitiesCalls.get())
assertTrue(observedEvents.any { it is CardDetailUiEvent.ShowSnackbar })
}
@Test
fun timelineRowsUseGivenOrderAndCommentBodyOnly() {
fakeDataSource.activities = listOf(
CardActivity(id = "a-new", type = "comment", text = "**body**", createdAtEpochMillis = 2_000L),
CardActivity(id = "a-old", type = "update", text = "ignored", createdAtEpochMillis = 1_000L),
)
launchCardDetail()
onView(withText(containsString("Someone commented"))).check(matches(isDisplayed()))
onView(withText(containsString("Someone updated this card"))).check(matches(isDisplayed()))
onView(withText(containsString("body"))).check(matches(isDisplayed()))
assertEquals(listOf("a-new", "a-old"), observedStates.last().activities.map { it.id })
}
@Test
fun sessionExpiredEventShowsDialogAndNavigatesToMainClearTask() {
fakeDataSource.loadResults.add(DataSourceResult.SessionExpired)
launchCardDetail()
onView(withText(R.string.card_detail_session_expired)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.ok)).inRoot(isDialog()).perform(click())
assertTrue(
Intents.getIntents().any { intent ->
intent.component?.className == MainActivity::class.java.name &&
(intent.flags and (Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)) ==
(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
},
)
}
@Test
fun deterministicHarnessUsesFakeDataSourceAndSynchronizationHooks() {
val titleGate = CompletableDeferred<Unit>()
fakeDataSource.updateTitleGate = titleGate
val scenario = launchCardDetail()
onView(withId(R.id.cardDetailTitleInput)).perform(replaceText("Blocked title"), closeSoftKeyboard())
scenario.onActivity {
val input = it.findViewById<TextView>(R.id.cardDetailTitleInput)
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
}
awaitCondition { fakeDataSource.updateTitleCalls.get() == 1 }
awaitCondition { observedStates.lastOrNull()?.isTitleSaving == true }
onView(withId(R.id.cardDetailTitleSavingText)).check(matches(isDisplayed()))
titleGate.complete(Unit)
awaitCondition { observedStates.lastOrNull()?.isTitleSaving == false }
onView(withId(R.id.cardDetailTitleSavingText)).check(matches(not(isDisplayed())))
}
@Test
fun placeholderCardDetailRouteIsNotDeclaredInManifest() {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val placeholderComponent = ComponentName(
context.packageName,
"space.hackenslacker.kanbn4droid.app.CardDetailPlaceholderActivity",
)
val isDeclared = runCatching {
@Suppress("DEPRECATION")
context.packageManager.getActivityInfo(placeholderComponent, PackageManager.GET_META_DATA)
}.isSuccess
assertTrue(!isDeclared)
}
private fun launchCardDetail(
cardId: String? = "card-1",
waitForContent: Boolean = true,
): ActivityScenario<CardDetailActivity> {
val intent = Intent(
androidx.test.core.app.ApplicationProvider.getApplicationContext(),
CardDetailActivity::class.java,
)
if (cardId != null) {
intent.putExtra(CardDetailActivity.EXTRA_CARD_ID, cardId)
}
intent.putExtra(CardDetailActivity.EXTRA_CARD_TITLE, "Card 1")
return ActivityScenario.launch<CardDetailActivity>(intent).also {
if (waitForContent && cardId != null) {
awaitCondition {
observedStates.lastOrNull()?.isInitialLoading == false &&
observedStates.lastOrNull()?.loadErrorMessage == null
}
}
}
}
private fun withCurrentTextColor(expectedColor: Int): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("with text color: $expectedColor")
}
override fun matchesSafely(item: View): Boolean {
if (item !is TextView) {
return false
}
return item.currentTextColor == expectedColor
}
}
}
private fun awaitCondition(timeoutMs: Long = 4_000L, condition: () -> Boolean) {
val instrumentation = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation()
val start = System.currentTimeMillis()
while (System.currentTimeMillis() - start < timeoutMs) {
instrumentation.waitForIdleSync()
if (condition()) {
return
}
Thread.sleep(50)
}
throw AssertionError("Condition not met within ${timeoutMs}ms")
}
private class FakeCardDetailDataSource : CardDetailDataSource {
var currentCard = CardDetail(
id = "card-1",
title = "Card 1",
description = "Seed description",
dueDate = null,
listPublicId = "list-1",
index = 0,
tags = listOf(CardDetailTag("tag-1", "Backend", "#008080")),
)
var activities = listOf(
CardActivity("activity-1", "update", "", 1_000L),
)
val loadResults: ArrayDeque<DataSourceResult<CardDetail>> = ArrayDeque()
val updateTitleResults: ArrayDeque<DataSourceResult<Unit>> = ArrayDeque()
val updateDescriptionResults: ArrayDeque<DataSourceResult<Unit>> = ArrayDeque()
val updateDueDateResults: ArrayDeque<DataSourceResult<Unit>> = ArrayDeque()
val listActivitiesResults: ArrayDeque<DataSourceResult<List<CardActivity>>> = ArrayDeque()
val addCommentResults: ArrayDeque<DataSourceResult<Unit>> = ArrayDeque()
var loadGate: CompletableDeferred<Unit>? = null
var updateTitleGate: CompletableDeferred<Unit>? = null
var updateDescriptionGate: CompletableDeferred<Unit>? = null
var updateDueDateGate: CompletableDeferred<Unit>? = null
val updateTitleCalls = AtomicInteger(0)
val updateDescriptionCalls = AtomicInteger(0)
val updateDueDateCalls = AtomicInteger(0)
val listActivitiesCalls = AtomicInteger(0)
val addCommentCalls = AtomicInteger(0)
var lastDueDate: LocalDate? = null
override suspend fun loadCard(cardId: String): DataSourceResult<CardDetail> {
loadGate?.await()
return if (loadResults.isNotEmpty()) loadResults.removeFirst() else DataSourceResult.Success(currentCard)
}
override suspend fun updateTitle(cardId: String, title: String): DataSourceResult<Unit> {
updateTitleCalls.incrementAndGet()
updateTitleGate?.await()
val result = if (updateTitleResults.isNotEmpty()) updateTitleResults.removeFirst() else DataSourceResult.Success(Unit)
if (result is DataSourceResult.Success) {
currentCard = currentCard.copy(title = title)
}
return result
}
override suspend fun updateDescription(cardId: String, description: String): DataSourceResult<Unit> {
updateDescriptionCalls.incrementAndGet()
updateDescriptionGate?.await()
val result = if (updateDescriptionResults.isNotEmpty()) updateDescriptionResults.removeFirst() else DataSourceResult.Success(Unit)
if (result is DataSourceResult.Success) {
currentCard = currentCard.copy(description = description)
}
return result
}
override suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult<Unit> {
updateDueDateCalls.incrementAndGet()
updateDueDateGate?.await()
val result = if (updateDueDateResults.isNotEmpty()) updateDueDateResults.removeFirst() else DataSourceResult.Success(Unit)
if (result is DataSourceResult.Success) {
lastDueDate = dueDate
currentCard = currentCard.copy(dueDate = dueDate)
}
return result
}
override suspend fun listActivities(cardId: String): DataSourceResult<List<CardActivity>> {
listActivitiesCalls.incrementAndGet()
return if (listActivitiesResults.isNotEmpty()) listActivitiesResults.removeFirst() else DataSourceResult.Success(activities)
}
override suspend fun addComment(cardId: String, comment: String): DataSourceResult<List<CardActivity>> {
addCommentCalls.incrementAndGet()
val result = if (addCommentResults.isNotEmpty()) addCommentResults.removeFirst() else DataSourceResult.Success(Unit)
if (result is DataSourceResult.Success) {
activities = listOf(
CardActivity("comment-${addCommentCalls.get()}", "comment", comment, System.currentTimeMillis()),
) + activities
return DataSourceResult.Success(activities)
}
return when (result) {
is DataSourceResult.GenericError -> result
DataSourceResult.SessionExpired -> DataSourceResult.SessionExpired
is DataSourceResult.Success -> DataSourceResult.Success(activities)
}
}
}
}

View File

@@ -21,6 +21,7 @@ import space.hackenslacker.kanbn4droid.app.auth.AuthFailureReason
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.AuthResult import space.hackenslacker.kanbn4droid.app.auth.AuthResult
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.SessionStore import space.hackenslacker.kanbn4droid.app.auth.SessionStore
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@@ -200,5 +201,13 @@ class LoginFlowTest {
private val result: AuthResult, private val result: AuthResult,
) : KanbnApiClient { ) : KanbnApiClient {
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = result override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = result
override suspend fun getLabelByPublicId(
baseUrl: String,
apiKey: String,
labelId: String,
): space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult<LabelDetail> {
return space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult.Failure("Not needed in login tests")
}
} }
} }

View File

@@ -16,7 +16,10 @@ class PreferencesApiKeyStoreTest {
@Before @Before
fun clearStore() = runBlocking { fun clearStore() = runBlocking {
store.invalidateApiKey("setup").getOrThrow() context.getSharedPreferences("kanbn_api_key_store", android.content.Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
} }
@Test @Test
@@ -47,15 +50,56 @@ class PreferencesApiKeyStoreTest {
} }
@Test @Test
fun blankAndMalformedBaseUrlStillWork() = runBlocking { fun saveGetForDifferentBaseUrlsAreIsolated() = runBlocking {
val saveBlankResult = store.saveApiKey("", "kan_key") store.saveApiKey("https://kan.bn/", "kan_key").getOrThrow()
store.saveApiKey("https://next.kan.bn/", "next_key").getOrThrow()
val firstResult = store.getApiKey("https://kan.bn/")
val secondResult = store.getApiKey("https://next.kan.bn/")
assertEquals("kan_key", firstResult.getOrNull())
assertEquals("next_key", secondResult.getOrNull())
}
@Test
fun invalidateOneBaseUrlDoesNotRemoveOtherBaseUrlKey() = runBlocking {
store.saveApiKey("https://kan.bn/", "kan_key").getOrThrow()
store.saveApiKey("https://next.kan.bn/", "next_key").getOrThrow()
val invalidateResult = store.invalidateApiKey("https://kan.bn/")
val removedResult = store.getApiKey("https://kan.bn/")
val keptResult = store.getApiKey("https://next.kan.bn/")
assertEquals(true, invalidateResult.isSuccess)
assertNull(removedResult.getOrNull())
assertEquals("next_key", keptResult.getOrNull())
}
@Test
fun keyDerivationUsesNormalizedBaseUrl() = runBlocking {
val saveResult = store.saveApiKey(" HTTPS://KAN.BN/api/v1 ", "kan_key")
val getNormalizedResult = store.getApiKey("https://kan.bn/api/v1/")
val getEquivalentResult = store.getApiKey("https://kan.bn/api/v1")
assertEquals(true, saveResult.isSuccess)
assertEquals("kan_key", getNormalizedResult.getOrNull())
assertEquals("kan_key", getEquivalentResult.getOrNull())
}
@Test
fun malformedBaseUrlStillUsesDeterministicFallbackKeying() = runBlocking {
val saveBlankResult = store.saveApiKey("", "blank_key")
val getBlankResult = store.getApiKey(" ")
val saveMalformedResult = store.saveApiKey("not a url", "bad_url_key")
val getMalformedResult = store.getApiKey("not a url") val getMalformedResult = store.getApiKey("not a url")
val invalidateMalformedResult = store.invalidateApiKey("::://") val invalidateMalformedResult = store.invalidateApiKey("::://")
val getAfterInvalidateResult = store.getApiKey(" ") val getAfterUnrelatedInvalidateResult = store.getApiKey("not a url")
assertEquals(true, saveBlankResult.isSuccess) assertEquals(true, saveBlankResult.isSuccess)
assertEquals("kan_key", getMalformedResult.getOrNull()) assertEquals("blank_key", getBlankResult.getOrNull())
assertEquals(true, saveMalformedResult.isSuccess)
assertEquals("bad_url_key", getMalformedResult.getOrNull())
assertEquals(true, invalidateMalformedResult.isSuccess) assertEquals(true, invalidateMalformedResult.isSuccess)
assertNull(getAfterInvalidateResult.getOrNull()) assertEquals("bad_url_key", getAfterUnrelatedInvalidateResult.getOrNull())
} }
} }

View File

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

View File

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

View File

@@ -12,15 +12,19 @@ import android.widget.TextView
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.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
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.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
@@ -31,23 +35,53 @@ import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
import space.hackenslacker.kanbn4droid.app.auth.SessionStore import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardsAdapter import space.hackenslacker.kanbn4droid.app.boards.BoardsAdapter
import space.hackenslacker.kanbn4droid.app.boards.BoardsDrawerAdapter
import space.hackenslacker.kanbn4droid.app.boards.BoardsRepository import space.hackenslacker.kanbn4droid.app.boards.BoardsRepository
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiEvent import space.hackenslacker.kanbn4droid.app.boards.BoardsUiEvent
import space.hackenslacker.kanbn4droid.app.boards.BoardsUiState import space.hackenslacker.kanbn4droid.app.boards.BoardsUiState
import space.hackenslacker.kanbn4droid.app.boards.BoardsViewModel import space.hackenslacker.kanbn4droid.app.boards.BoardsViewModel
import space.hackenslacker.kanbn4droid.app.boards.DrawerDataErrorCode
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity
import space.hackenslacker.kanbn4droid.app.settings.SettingsDialogFragment
class BoardsActivity : AppCompatActivity() { class BoardsActivity : AppCompatActivity() {
private lateinit var sessionStore: SessionStore private lateinit var sessionStore: SessionStore
private lateinit var apiKeyStore: ApiKeyStore private lateinit var apiKeyStore: ApiKeyStore
private lateinit var apiClient: KanbnApiClient private lateinit var apiClient: KanbnApiClient
internal val sessionStoreForSettingsDialog: SessionStore
get() = sessionStore
internal val apiKeyStoreForSettingsDialog: ApiKeyStore
get() = apiKeyStore
internal val apiClientForSettingsDialog: KanbnApiClient
get() = apiClient
private lateinit var swipeRefresh: SwipeRefreshLayout private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var recyclerView: RecyclerView private lateinit var recyclerView: RecyclerView
private lateinit var emptyStateText: TextView private lateinit var emptyStateText: TextView
private lateinit var initialProgress: ProgressBar private lateinit var initialProgress: ProgressBar
private lateinit var createFab: FloatingActionButton private lateinit var createFab: FloatingActionButton
private lateinit var toolbar: MaterialToolbar
private lateinit var drawerLayout: DrawerLayout
private lateinit var drawerContent: View
private lateinit var drawerUsernameText: TextView
private lateinit var drawerEmailText: TextView
private lateinit var drawerLoadingIndicator: ProgressBar
private lateinit var drawerErrorText: TextView
private lateinit var drawerRetryButton: Button
private lateinit var drawerSettingsButton: Button
private lateinit var drawerLogoutButton: Button
private lateinit var drawerWorkspacesRecyclerView: RecyclerView
private lateinit var boardsAdapter: BoardsAdapter private lateinit var boardsAdapter: BoardsAdapter
private lateinit var drawerAdapter: BoardsDrawerAdapter
private var hasRequestedInitialDrawerLoad = false
private var lastDrawerSwitchInFlight = false
private var pendingWorkspaceSelectionId: String? = null
private var pendingOpenSettingsAfterDrawerClose = false
private val viewModel: BoardsViewModel by viewModels { private val viewModel: BoardsViewModel by viewModels {
BoardsViewModel.Factory( BoardsViewModel.Factory(
@@ -71,6 +105,7 @@ class BoardsActivity : AppCompatActivity() {
setupRecycler() setupRecycler()
setupInteractions() setupInteractions()
observeViewModel() observeViewModel()
observeSettingsResult()
viewModel.loadBoards() viewModel.loadBoards()
} }
@@ -81,11 +116,24 @@ class BoardsActivity : AppCompatActivity() {
} }
private fun bindViews() { private fun bindViews() {
drawerLayout = findViewById(R.id.boardsDrawerLayout)
drawerContent = findViewById(R.id.boardsDrawerContent)
toolbar = findViewById(R.id.boardsToolbar)
swipeRefresh = findViewById(R.id.boardsSwipeRefresh) swipeRefresh = findViewById(R.id.boardsSwipeRefresh)
recyclerView = findViewById(R.id.boardsRecyclerView) recyclerView = findViewById(R.id.boardsRecyclerView)
emptyStateText = findViewById(R.id.boardsEmptyStateText) emptyStateText = findViewById(R.id.boardsEmptyStateText)
initialProgress = findViewById(R.id.boardsInitialProgress) initialProgress = findViewById(R.id.boardsInitialProgress)
createFab = findViewById(R.id.createBoardFab) createFab = findViewById(R.id.createBoardFab)
drawerUsernameText = findViewById(R.id.drawerUsernameText)
drawerEmailText = findViewById(R.id.drawerEmailText)
drawerLoadingIndicator = findViewById(R.id.drawerLoadingIndicator)
drawerErrorText = findViewById(R.id.drawerErrorText)
drawerRetryButton = findViewById(R.id.drawerRetryButton)
drawerSettingsButton = findViewById(R.id.drawerSettingsButton)
drawerLogoutButton = findViewById(R.id.drawerLogoutButton)
drawerWorkspacesRecyclerView = findViewById(R.id.drawerWorkspacesRecyclerView)
applyDrawerWidth()
} }
private fun setupRecycler() { private fun setupRecycler() {
@@ -95,9 +143,64 @@ class BoardsActivity : AppCompatActivity() {
) )
recyclerView.layoutManager = LinearLayoutManager(this) recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = boardsAdapter recyclerView.adapter = boardsAdapter
drawerAdapter = BoardsDrawerAdapter(
onWorkspaceClick = { workspace ->
if (viewModel.uiState.value.drawer.isWorkspaceInteractionEnabled) {
pendingWorkspaceSelectionId = workspace.id
viewModel.onWorkspaceSelected(workspace.id)
}
},
)
drawerWorkspacesRecyclerView.layoutManager = LinearLayoutManager(this)
drawerWorkspacesRecyclerView.adapter = drawerAdapter
} }
private fun setupInteractions() { private fun setupInteractions() {
toolbar.setNavigationIcon(android.R.drawable.ic_menu_sort_by_size)
toolbar.setNavigationContentDescription(R.string.drawer_workspaces)
toolbar.setNavigationOnClickListener {
drawerLayout.openDrawer(GravityCompat.START)
}
drawerRetryButton.setOnClickListener {
viewModel.retryDrawerData()
}
drawerSettingsButton.setOnClickListener {
sessionStore.initializeDraftsFromCommitted()
if (drawerLayout.isDrawerOpen(GravityCompat.START)) {
pendingOpenSettingsAfterDrawerClose = true
drawerLayout.closeDrawer(GravityCompat.START)
} else {
showSettingsDialog()
}
}
drawerLogoutButton.setOnClickListener {
showLogoutConfirmation()
}
drawerLayout.addDrawerListener(
object : DrawerLayout.SimpleDrawerListener() {
override fun onDrawerOpened(drawerView: View) {
if (drawerView.id == R.id.boardsDrawerContent) {
viewModel.loadDrawerDataIfStale()
}
}
override fun onDrawerClosed(drawerView: View) {
if (drawerView.id != R.id.boardsDrawerContent) {
return
}
if (pendingOpenSettingsAfterDrawerClose) {
pendingOpenSettingsAfterDrawerClose = false
showSettingsDialog()
}
}
},
)
swipeRefresh.setOnRefreshListener { swipeRefresh.setOnRefreshListener {
viewModel.refreshBoards() viewModel.refreshBoards()
} }
@@ -125,17 +228,98 @@ class BoardsActivity : AppCompatActivity() {
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
.show() .show()
} }
BoardsUiEvent.ForceSignOut -> {
forceSignOutToLogin()
}
} }
} }
} }
} }
private fun render(state: BoardsUiState) { private fun render(state: BoardsUiState) {
if (!hasRequestedInitialDrawerLoad) {
hasRequestedInitialDrawerLoad = true
viewModel.loadDrawerData()
}
boardsAdapter.submitBoards(state.boards) boardsAdapter.submitBoards(state.boards)
swipeRefresh.isRefreshing = state.isRefreshing swipeRefresh.isRefreshing = state.isRefreshing
initialProgress.visibility = if (state.isInitialLoading) View.VISIBLE else View.GONE initialProgress.visibility = if (state.isInitialLoading) View.VISIBLE else View.GONE
emptyStateText.visibility = if (!state.isInitialLoading && state.boards.isEmpty()) View.VISIBLE else View.GONE emptyStateText.visibility = if (!state.isInitialLoading && state.boards.isEmpty()) View.VISIBLE else View.GONE
createFab.isEnabled = !state.isMutating createFab.isEnabled = !state.isMutating
val profile = state.drawer.profile
drawerUsernameText.text = profile?.displayName?.ifBlank { getString(R.string.drawer_profile_unavailable) }
?: getString(R.string.drawer_profile_unavailable)
drawerEmailText.text = profile?.email?.ifBlank { getString(R.string.drawer_profile_unavailable) }
?: getString(R.string.drawer_profile_unavailable)
drawerAdapter.submitItems(
workspaces = state.drawer.workspaces,
activeWorkspaceId = state.drawer.activeWorkspaceId,
)
drawerLoadingIndicator.visibility = if (state.drawer.isLoading) View.VISIBLE else View.GONE
val hasError = state.drawer.profileError != null || state.drawer.workspacesError != null
drawerErrorText.visibility = if (hasError) View.VISIBLE else View.GONE
drawerErrorText.text = state.drawer.workspacesError
?: state.drawer.profileError
?: getString(R.string.drawer_workspaces_unavailable)
drawerRetryButton.visibility = if (state.drawer.isRetryable) View.VISIBLE else View.GONE
val isSwitchInFlight = state.drawer.isWorkspaceSwitchInFlight
if (lastDrawerSwitchInFlight && !isSwitchInFlight) {
val selectedWorkspace = pendingWorkspaceSelectionId
if (
selectedWorkspace != null &&
selectedWorkspace == state.drawer.activeWorkspaceId &&
state.drawer.errorCode == DrawerDataErrorCode.NONE
) {
drawerLayout.closeDrawer(GravityCompat.START)
}
pendingWorkspaceSelectionId = null
}
lastDrawerSwitchInFlight = isSwitchInFlight
}
private fun applyDrawerWidth() {
val maxWidthPx = resources.getDimensionPixelSize(R.dimen.boards_drawer_max_width)
val displayWidthPx = resources.displayMetrics.widthPixels
val computedWidth = minOf(displayWidthPx / 3, maxWidthPx)
drawerContent.layoutParams = drawerContent.layoutParams.apply {
width = computedWidth
}
}
private fun forceSignOutToLogin() {
lifecycleScope.launch {
val baseUrl = sessionStore.getBaseUrl().orEmpty()
if (baseUrl.isNotBlank()) {
kotlinx.coroutines.withContext(Dispatchers.IO) {
apiKeyStore.invalidateApiKey(baseUrl)
}
}
sessionStore.clearBaseUrl()
sessionStore.clearApiKey()
sessionStore.clearWorkspaceId()
sessionStore.resetDraftsFromCommitted()
viewModel.clearSessionUiState()
val intent = Intent(this@BoardsActivity, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(intent)
finish()
}
}
private fun showLogoutConfirmation() {
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.drawer_logout_confirmation_message))
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.drawer_logout) { _, _ ->
forceSignOutToLogin()
}
.show()
} }
private fun showCreateBoardDialog() { private fun showCreateBoardDialog() {
@@ -243,11 +427,33 @@ class BoardsActivity : AppCompatActivity() {
.show() .show()
} }
private fun observeSettingsResult() {
supportFragmentManager.setFragmentResultListener(
SettingsDialogFragment.REQUEST_KEY_SETTINGS_APPLIED,
this,
) { _, bundle ->
val credentialsChanged = bundle.getBoolean(SettingsDialogFragment.RESULT_KEY_CREDENTIALS_CHANGED)
val themeChanged = bundle.getBoolean(SettingsDialogFragment.RESULT_KEY_THEME_CHANGED)
viewModel.onSettingsApplied(
credentialsChanged = credentialsChanged,
)
}
}
private fun showSettingsDialog() {
if (supportFragmentManager.findFragmentByTag(SettingsDialogFragment.TAG) != null) {
return
}
SettingsDialogFragment
.newInstance()
.show(supportFragmentManager, SettingsDialogFragment.TAG)
}
private fun navigateToBoard(board: BoardSummary) { private fun navigateToBoard(board: BoardSummary) {
startActivity( startActivity(
Intent(this, BoardDetailPlaceholderActivity::class.java) Intent(this, BoardDetailActivity::class.java)
.putExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_ID, board.id) .putExtra(BoardDetailActivity.EXTRA_BOARD_ID, board.id)
.putExtra(BoardDetailPlaceholderActivity.EXTRA_BOARD_TITLE, board.title), .putExtra(BoardDetailActivity.EXTRA_BOARD_TITLE, board.title),
) )
} }

View File

@@ -24,6 +24,7 @@ import space.hackenslacker.kanbn4droid.app.auth.AuthResult
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.PreferencesApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.PreferencesApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyCoordinator
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
import space.hackenslacker.kanbn4droid.app.auth.SessionStore import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.auth.UrlNormalizer import space.hackenslacker.kanbn4droid.app.auth.UrlNormalizer
@@ -225,10 +226,12 @@ class TestDependencies {
var sessionStoreFactory: ((AppCompatActivity) -> SessionStore)? = null var sessionStoreFactory: ((AppCompatActivity) -> SessionStore)? = null
var apiKeyStoreFactory: ((AppCompatActivity) -> ApiKeyStore)? = null var apiKeyStoreFactory: ((AppCompatActivity) -> ApiKeyStore)? = null
var apiClientFactory: (() -> KanbnApiClient)? = null var apiClientFactory: (() -> KanbnApiClient)? = null
var settingsApplyCoordinatorFactory: ((AppCompatActivity, SessionStore, KanbnApiClient, ApiKeyStore) -> SettingsApplyCoordinator)? = null
fun clear() { fun clear() {
sessionStoreFactory = null sessionStoreFactory = null
apiKeyStoreFactory = null apiKeyStoreFactory = null
apiClientFactory = null apiClientFactory = null
settingsApplyCoordinatorFactory = null
} }
} }

View File

@@ -1,6 +1,7 @@
package space.hackenslacker.kanbn4droid.app.auth package space.hackenslacker.kanbn4droid.app.auth
import android.content.Context import android.content.Context
import java.security.MessageDigest
interface ApiKeyStore { interface ApiKeyStore {
suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit>
@@ -15,26 +16,71 @@ class PreferencesApiKeyStore(
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> { override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
return runCatching { return runCatching {
preferences.edit().putString(API_KEY_PREFERENCE_KEY, apiKey).apply() val preferenceKey = preferenceKeyForBaseUrl(baseUrl)
preferences.edit()
.remove(LEGACY_API_KEY_PREFERENCE_KEY)
.putString(preferenceKey, apiKey)
.apply()
Unit Unit
} }
} }
override suspend fun getApiKey(baseUrl: String): Result<String?> { override suspend fun getApiKey(baseUrl: String): Result<String?> {
return runCatching { return runCatching {
preferences.getString(API_KEY_PREFERENCE_KEY, null) val preferenceKey = preferenceKeyForBaseUrl(baseUrl)
val keyedValue = preferences.getString(preferenceKey, null)
if (keyedValue != null) {
keyedValue
} else {
val legacyValue = preferences.getString(LEGACY_API_KEY_PREFERENCE_KEY, null)
if (legacyValue != null) {
preferences.edit()
.remove(LEGACY_API_KEY_PREFERENCE_KEY)
.putString(preferenceKey, legacyValue)
.apply()
}
legacyValue
}
} }
} }
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> { override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
return runCatching { return runCatching {
preferences.edit().remove(API_KEY_PREFERENCE_KEY).apply() val preferenceKey = preferenceKeyForBaseUrl(baseUrl)
preferences.edit().remove(preferenceKey).apply()
Unit Unit
} }
} }
internal fun preferenceKeyForBaseUrl(baseUrl: String): String {
return deriveApiKeyPreferenceKey(baseUrl)
}
private companion object { private companion object {
private const val PREFERENCES_NAME = "kanbn_api_key_store" private const val PREFERENCES_NAME = "kanbn_api_key_store"
private const val API_KEY_PREFERENCE_KEY = "api_key" private const val API_KEY_PREFERENCE_KEY_PREFIX = "api_key_"
private const val LEGACY_API_KEY_PREFERENCE_KEY = "api_key"
} }
} }
internal fun deriveApiKeyPreferenceKey(baseUrl: String): String {
val normalizedBaseUrl = when (val normalized = UrlNormalizer.normalize(baseUrl)) {
is UrlValidationResult.Valid -> normalized.normalizedUrl
is UrlValidationResult.Invalid -> baseUrl.trim()
}
val digest = MessageDigest.getInstance("SHA-256")
.digest(normalizedBaseUrl.toByteArray(Charsets.UTF_8))
return "api_key_${digest.toHexString()}"
}
private fun ByteArray.toHexString(): String {
val builder = StringBuilder(size * 2)
for (byte in this) {
val value = byte.toInt() and 0xff
if (value < 0x10) {
builder.append('0')
}
builder.append(value.toString(16))
}
return builder.toString()
}

View File

@@ -7,12 +7,28 @@ class SessionPreferences(context: Context) : SessionStore {
private val preferences: SharedPreferences = private val preferences: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
override fun getThemeMode(): String = preferences.getString(KEY_THEME_MODE, THEME_MODE_SYSTEM) ?: THEME_MODE_SYSTEM
override fun saveThemeMode(themeMode: String) {
preferences.edit().putString(KEY_THEME_MODE, themeMode).apply()
}
override fun getBaseUrl(): String? = preferences.getString(KEY_BASE_URL, null) override fun getBaseUrl(): String? = preferences.getString(KEY_BASE_URL, null)
override fun saveBaseUrl(url: String) { override fun saveBaseUrl(url: String) {
preferences.edit().putString(KEY_BASE_URL, url).apply() preferences.edit().putString(KEY_BASE_URL, url).apply()
} }
override fun getApiKey(): String? = preferences.getString(KEY_API_KEY, null)
override fun saveApiKey(apiKey: String) {
preferences.edit().putString(KEY_API_KEY, apiKey).apply()
}
override fun clearApiKey() {
preferences.edit().remove(KEY_API_KEY).apply()
}
override fun getWorkspaceId(): String? = preferences.getString(KEY_WORKSPACE_ID, null) override fun getWorkspaceId(): String? = preferences.getString(KEY_WORKSPACE_ID, null)
override fun saveWorkspaceId(workspaceId: String) { override fun saveWorkspaceId(workspaceId: String) {
@@ -27,9 +43,55 @@ class SessionPreferences(context: Context) : SessionStore {
preferences.edit().remove(KEY_WORKSPACE_ID).apply() preferences.edit().remove(KEY_WORKSPACE_ID).apply()
} }
override fun initializeDraftsFromCommitted() {
val editor = preferences.edit()
editor.putString(KEY_DRAFT_THEME_MODE, getThemeMode())
editor.putString(KEY_DRAFT_BASE_URL, getBaseUrl())
editor.putString(KEY_DRAFT_API_KEY, getApiKey())
editor.apply()
}
override fun getDraftThemeMode(): String {
return preferences.getString(KEY_DRAFT_THEME_MODE, getThemeMode()) ?: getThemeMode()
}
override fun saveDraftThemeMode(themeMode: String) {
preferences.edit().putString(KEY_DRAFT_THEME_MODE, themeMode).apply()
}
override fun getDraftBaseUrl(): String? = preferences.getString(KEY_DRAFT_BASE_URL, getBaseUrl())
override fun saveDraftBaseUrl(url: String) {
preferences.edit().putString(KEY_DRAFT_BASE_URL, url).apply()
}
override fun getDraftApiKey(): String? = preferences.getString(KEY_DRAFT_API_KEY, getApiKey())
override fun saveDraftApiKey(apiKey: String) {
preferences.edit().putString(KEY_DRAFT_API_KEY, apiKey).apply()
}
override fun resetDraftsFromCommitted() {
initializeDraftsFromCommitted()
}
override fun syncDraftsToCommitted() {
val editor = preferences.edit()
editor.putString(KEY_THEME_MODE, getDraftThemeMode())
editor.putString(KEY_BASE_URL, getDraftBaseUrl())
editor.putString(KEY_API_KEY, getDraftApiKey())
editor.apply()
}
private companion object { private companion object {
private const val PREFS_NAME = "kanbn_session" private const val PREFS_NAME = "kanbn_session"
private const val THEME_MODE_SYSTEM = "system"
private const val KEY_THEME_MODE = "theme_mode"
private const val KEY_BASE_URL = "base_url" private const val KEY_BASE_URL = "base_url"
private const val KEY_API_KEY = "api_key"
private const val KEY_WORKSPACE_ID = "workspace_id" private const val KEY_WORKSPACE_ID = "workspace_id"
private const val KEY_DRAFT_THEME_MODE = "draft_theme_mode"
private const val KEY_DRAFT_BASE_URL = "draft_base_url"
private const val KEY_DRAFT_API_KEY = "draft_api_key"
} }
} }

View File

@@ -1,10 +1,41 @@
package space.hackenslacker.kanbn4droid.app.auth package space.hackenslacker.kanbn4droid.app.auth
interface SessionStore { interface SessionStore {
fun getThemeMode(): String = "system"
fun saveThemeMode(themeMode: String) {}
fun getBaseUrl(): String? fun getBaseUrl(): String?
fun saveBaseUrl(url: String) fun saveBaseUrl(url: String)
fun getApiKey(): String? = null
fun saveApiKey(apiKey: String) {}
fun clearApiKey() {}
fun getWorkspaceId(): String? fun getWorkspaceId(): String?
fun saveWorkspaceId(workspaceId: String) fun saveWorkspaceId(workspaceId: String)
fun clearBaseUrl() fun clearBaseUrl()
fun clearWorkspaceId() fun clearWorkspaceId()
fun initializeDraftsFromCommitted() {}
fun getDraftThemeMode(): String = getThemeMode()
fun saveDraftThemeMode(themeMode: String) {
saveThemeMode(themeMode)
}
fun getDraftBaseUrl(): String? = getBaseUrl()
fun saveDraftBaseUrl(url: String) {
saveBaseUrl(url)
}
fun getDraftApiKey(): String? = getApiKey()
fun saveDraftApiKey(apiKey: String) {
saveApiKey(apiKey)
}
fun resetDraftsFromCommitted() {
initializeDraftsFromCommitted()
}
fun syncDraftsToCommitted() {
saveThemeMode(getDraftThemeMode())
getDraftBaseUrl()?.let(::saveBaseUrl) ?: clearBaseUrl()
getDraftApiKey()?.let(::saveApiKey) ?: clearApiKey()
}
} }

View File

@@ -0,0 +1,146 @@
package space.hackenslacker.kanbn4droid.app.auth
sealed interface SettingsApplyResult {
data object NoChanges : SettingsApplyResult
data class SuccessNoCredentialChange(val themeChanged: Boolean) : SettingsApplyResult
data object SuccessCredentialChange : SettingsApplyResult
data class ValidationError(val field: String, val message: String) : SettingsApplyResult
data class AuthError(val message: String) : SettingsApplyResult
data class NetworkError(val message: String) : SettingsApplyResult
}
open class SettingsApplyCoordinator(
private val sessionStore: SessionStore,
private val apiClient: KanbnApiClient,
private val apiKeyStore: ApiKeyStore,
) {
open suspend fun apply(): SettingsApplyResult {
val snapshot = try {
LastKnownGoodSnapshot.capture(sessionStore, apiKeyStore)
} catch (_: Exception) {
return SettingsApplyResult.NetworkError("Failed to apply settings changes.")
}
val draftTheme = sessionStore.getDraftThemeMode().trim().ifBlank { "system" }
val draftBaseUrlRaw = sessionStore.getDraftBaseUrl().orEmpty()
val draftApiKey = sessionStore.getDraftApiKey().orEmpty().trim()
val normalizedDraftBaseUrl = when (val normalized = UrlNormalizer.normalize(draftBaseUrlRaw)) {
is UrlValidationResult.Valid -> normalized.normalizedUrl
is UrlValidationResult.Invalid -> {
sessionStore.resetDraftsFromCommitted()
return SettingsApplyResult.ValidationError(
field = FIELD_BASE_URL,
message = normalized.message,
)
}
}
if (draftApiKey.isBlank()) {
sessionStore.resetDraftsFromCommitted()
return SettingsApplyResult.ValidationError(
field = FIELD_API_KEY,
message = "API key is required",
)
}
val themeChanged = draftTheme != snapshot.committedThemeMode
val committedApiKey = snapshot.committedApiStoreKey.orEmpty()
val credentialChanged = normalizedDraftBaseUrl != snapshot.committedBaseUrl ||
draftApiKey != committedApiKey
if (!themeChanged && !credentialChanged) {
return SettingsApplyResult.NoChanges
}
if (credentialChanged) {
when (val auth = apiClient.healthCheck(normalizedDraftBaseUrl, draftApiKey)) {
is AuthResult.Success -> Unit
is AuthResult.Failure -> {
rollback(snapshot, normalizedDraftBaseUrl)
return when (auth.reason) {
AuthFailureReason.Authentication -> SettingsApplyResult.AuthError(auth.message)
AuthFailureReason.Connectivity,
AuthFailureReason.Server,
AuthFailureReason.Unexpected,
-> SettingsApplyResult.NetworkError(auth.message)
}
}
}
}
try {
sessionStore.saveDraftThemeMode(draftTheme)
sessionStore.saveDraftBaseUrl(normalizedDraftBaseUrl)
sessionStore.saveDraftApiKey(draftApiKey)
if (credentialChanged) {
apiKeyStore.saveApiKey(normalizedDraftBaseUrl, draftApiKey).getOrThrow()
if (normalizedDraftBaseUrl != snapshot.committedBaseUrl) {
apiKeyStore.invalidateApiKey(snapshot.committedBaseUrl).getOrThrow()
}
}
sessionStore.syncDraftsToCommitted()
} catch (_: Exception) {
rollback(snapshot, normalizedDraftBaseUrl)
return SettingsApplyResult.NetworkError("Failed to apply settings changes.")
}
return if (credentialChanged) {
SettingsApplyResult.SuccessCredentialChange
} else {
SettingsApplyResult.SuccessNoCredentialChange(themeChanged = themeChanged)
}
}
private suspend fun rollback(snapshot: LastKnownGoodSnapshot, candidateBaseUrl: String) {
snapshot.restoreSession(sessionStore)
snapshot.restoreApiKeys(apiKeyStore, candidateBaseUrl)
}
private data class LastKnownGoodSnapshot(
val committedThemeMode: String,
val committedBaseUrl: String,
val committedSessionApiKey: String,
val committedApiStoreKey: String?,
) {
fun restoreSession(sessionStore: SessionStore) {
sessionStore.saveThemeMode(committedThemeMode)
sessionStore.saveBaseUrl(committedBaseUrl)
sessionStore.saveApiKey(committedSessionApiKey)
sessionStore.saveDraftThemeMode(committedThemeMode)
sessionStore.saveDraftBaseUrl(committedBaseUrl)
sessionStore.saveDraftApiKey(committedSessionApiKey)
}
suspend fun restoreApiKeys(apiKeyStore: ApiKeyStore, candidateBaseUrl: String) {
if (candidateBaseUrl != committedBaseUrl) {
apiKeyStore.invalidateApiKey(candidateBaseUrl)
}
if (committedApiStoreKey == null) {
apiKeyStore.invalidateApiKey(committedBaseUrl)
} else {
apiKeyStore.saveApiKey(committedBaseUrl, committedApiStoreKey)
}
}
companion object {
suspend fun capture(sessionStore: SessionStore, apiKeyStore: ApiKeyStore): LastKnownGoodSnapshot {
val committedBaseUrl = sessionStore.getBaseUrl().orEmpty()
val committedApiStoreKey = apiKeyStore.getApiKey(committedBaseUrl).getOrThrow()
return LastKnownGoodSnapshot(
committedThemeMode = sessionStore.getThemeMode(),
committedBaseUrl = committedBaseUrl,
committedSessionApiKey = sessionStore.getApiKey().orEmpty(),
committedApiStoreKey = committedApiStoreKey,
)
}
}
}
private companion object {
private const val FIELD_BASE_URL = "baseUrl"
private const val FIELD_API_KEY = "apiKey"
}
}

View File

@@ -1,6 +1,7 @@
package space.hackenslacker.kanbn4droid.app.auth package space.hackenslacker.kanbn4droid.app.auth
import java.net.URI import java.net.URI
import java.util.Locale
sealed interface UrlValidationResult { sealed interface UrlValidationResult {
data class Valid(val normalizedUrl: String) : UrlValidationResult data class Valid(val normalizedUrl: String) : UrlValidationResult
@@ -26,7 +27,7 @@ object UrlNormalizer {
return UrlValidationResult.Invalid("Base URL must start with http:// or https://") return UrlValidationResult.Invalid("Base URL must start with http:// or https://")
} }
val host = uri.host val host = uri.host?.lowercase(Locale.ROOT)
if (host.isNullOrBlank()) { if (host.isNullOrBlank()) {
return UrlValidationResult.Invalid("Enter a valid server URL") return UrlValidationResult.Invalid("Enter a valid server URL")
} }

View File

@@ -0,0 +1,204 @@
package space.hackenslacker.kanbn4droid.app.auth
import space.hackenslacker.kanbn4droid.app.boards.DrawerProfile
internal fun parseUsersMeProfile(body: String): DrawerProfile? {
if (body.isBlank()) {
return null
}
val root = parseUsersMeJsonObject(body) ?: return null
val data = root["data"] as? Map<*, *>
val user = (data?.get("user") as? Map<*, *>)
?: (root["user"] as? Map<*, *>)
?: data
?: root
val displayName = extractUsersMeString(user, "displayName", "username", "name", "email")
if (displayName.isBlank()) {
return null
}
val email = extractUsersMeString(user, "email").ifBlank { null }
return DrawerProfile(displayName = displayName, email = email)
}
private fun extractUsersMeString(source: Map<*, *>, vararg keys: String): String {
return keys.firstNotNullOfOrNull { key -> source[key]?.toString()?.trim()?.takeIf { it.isNotEmpty() } }.orEmpty()
}
private fun parseUsersMeJsonObject(body: String): Map<String, Any?>? {
val parsed = parseUsersMeJsonValue(body)
@Suppress("UNCHECKED_CAST")
return parsed as? Map<String, Any?>
}
private fun parseUsersMeJsonValue(body: String): Any? {
val trimmed = body.trim()
if (trimmed.isBlank()) {
return null
}
return runCatching { UsersMeMiniJsonParser(trimmed).parseValue() }.getOrNull()
}
private class UsersMeMiniJsonParser(private val input: String) {
private var index = 0
fun parseValue(): Any? {
skipWhitespace()
if (index >= input.length) {
return null
}
return when (val ch = input[index]) {
'{' -> parseObject()
'[' -> parseArray()
'"' -> parseString()
't' -> parseLiteral("true", true)
'f' -> parseLiteral("false", false)
'n' -> parseLiteral("null", null)
'-', in '0'..'9' -> parseNumber()
else -> throw IllegalArgumentException("Unexpected token $ch at index $index")
}
}
private fun parseObject(): Map<String, Any?> {
expect('{')
skipWhitespace()
val result = linkedMapOf<String, Any?>()
if (peek() == '}') {
index += 1
return result
}
while (index < input.length) {
val key = parseString()
skipWhitespace()
expect(':')
val value = parseValue()
result[key] = value
skipWhitespace()
when (peek()) {
',' -> index += 1
'}' -> {
index += 1
return result
}
else -> throw IllegalArgumentException("Expected , or } at index $index")
}
skipWhitespace()
}
throw IllegalArgumentException("Unclosed object")
}
private fun parseArray(): List<Any?> {
expect('[')
skipWhitespace()
val result = mutableListOf<Any?>()
if (peek() == ']') {
index += 1
return result
}
while (index < input.length) {
result += parseValue()
skipWhitespace()
when (peek()) {
',' -> index += 1
']' -> {
index += 1
return result
}
else -> throw IllegalArgumentException("Expected , or ] at index $index")
}
skipWhitespace()
}
throw IllegalArgumentException("Unclosed array")
}
private fun parseString(): String {
expect('"')
val result = StringBuilder()
while (index < input.length) {
val ch = input[index++]
when (ch) {
'"' -> return result.toString()
'\\' -> {
val escaped = input.getOrNull(index++) ?: throw IllegalArgumentException("Invalid escape")
when (escaped) {
'"' -> result.append('"')
'\\' -> result.append('\\')
'/' -> result.append('/')
'b' -> result.append('\b')
'f' -> result.append('\u000C')
'n' -> result.append('\n')
'r' -> result.append('\r')
't' -> result.append('\t')
'u' -> {
val hex = input.substring(index, index + 4)
index += 4
result.append(hex.toInt(16).toChar())
}
else -> throw IllegalArgumentException("Invalid escape token")
}
}
else -> result.append(ch)
}
}
throw IllegalArgumentException("Unclosed string")
}
private fun parseNumber(): Any {
val start = index
if (peek() == '-') {
index += 1
}
while (peek()?.isDigit() == true) {
index += 1
}
var isFloating = false
if (peek() == '.') {
isFloating = true
index += 1
while (peek()?.isDigit() == true) {
index += 1
}
}
if (peek() == 'e' || peek() == 'E') {
isFloating = true
index += 1
if (peek() == '+' || peek() == '-') {
index += 1
}
while (peek()?.isDigit() == true) {
index += 1
}
}
val token = input.substring(start, index)
return if (isFloating) token.toDouble() else token.toLong()
}
private fun parseLiteral(token: String, value: Any?): Any? {
if (!input.startsWith(token, index)) {
throw IllegalArgumentException("Expected $token at index $index")
}
index += token.length
return value
}
private fun expect(expected: Char) {
skipWhitespace()
if (peek() != expected) {
throw IllegalArgumentException("Expected $expected at index $index")
}
index += 1
}
private fun peek(): Char? = input.getOrNull(index)
private fun skipWhitespace() {
while (peek()?.isWhitespace() == true) {
index += 1
}
}
}

View File

@@ -0,0 +1,841 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import android.app.DatePickerDialog
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.CheckBox
import android.widget.LinearLayout
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
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.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
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.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import com.google.android.material.snackbar.Snackbar
import java.text.DateFormat
import java.time.ZoneId
import java.util.Date
import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.MainActivity
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailActivity
import space.hackenslacker.kanbn4droid.app.R
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.PreferencesApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
class BoardDetailActivity : AppCompatActivity() {
private lateinit var boardId: String
private lateinit var sessionStore: SessionStore
private lateinit var apiKeyStore: ApiKeyStore
private lateinit var apiClient: KanbnApiClient
private lateinit var toolbar: MaterialToolbar
private lateinit var pager: ViewPager2
private lateinit var emptyBoardText: TextView
private lateinit var initialProgress: ProgressBar
private lateinit var fullScreenErrorContainer: View
private lateinit var fullScreenErrorText: TextView
private lateinit var retryButton: Button
private lateinit var createFab: FloatingActionButton
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 deleteSecondConfirmationDialog: AlertDialog? = null
private var dismissMoveDialogWhenMutationEnds: Boolean = false
private var dismissDeleteDialogWhenMutationEnds: Boolean = false
private var hasShownBlockingStartupError: Boolean = false
private lateinit var pagerAdapter: BoardListsPagerAdapter
private val viewModel: BoardDetailViewModel by viewModels {
val id = boardId
val fakeFactory = testDataSourceFactory
if (fakeFactory != null) {
object : androidx.lifecycle.ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(BoardDetailViewModel::class.java)) {
return BoardDetailViewModel(id, fakeFactory.invoke(id)) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}
} else {
BoardDetailViewModel.Factory(
boardId = id,
repository = BoardDetailRepository(
sessionStore = sessionStore,
apiKeyStore = apiKeyStore,
apiClient = apiClient,
),
)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
boardId = intent.getStringExtra(EXTRA_BOARD_ID).orEmpty()
sessionStore = provideSessionStore()
apiKeyStore = provideApiKeyStore()
apiClient = provideApiClient()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_board_detail)
bindViews()
setupToolbar()
setupPager()
observeViewModel()
if (boardId.isBlank()) {
showBlockingStartupErrorAndFinish(getString(R.string.board_detail_unable_to_open_board))
return
}
viewModel.loadBoardDetail()
}
override fun onBackPressed() {
if (!viewModel.onBackPressed()) {
super.onBackPressed()
}
}
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() {
toolbar = findViewById(R.id.boardDetailToolbar)
pager = findViewById(R.id.boardDetailPager)
emptyBoardText = findViewById(R.id.boardDetailEmptyBoardText)
initialProgress = findViewById(R.id.boardDetailInitialProgress)
fullScreenErrorContainer = findViewById(R.id.boardDetailFullScreenErrorContainer)
fullScreenErrorText = findViewById(R.id.boardDetailFullScreenErrorText)
retryButton = findViewById(R.id.boardDetailRetryButton)
createFab = findViewById(R.id.boardDetailCreateFab)
}
private fun setupToolbar() {
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
toolbar.setNavigationOnClickListener {
onBackPressed()
}
retryButton.setOnClickListener {
viewModel.retryLoad()
}
createFab.setOnClickListener {
viewModel.openFabChooser()
}
}
private fun setupPager() {
pagerAdapter = BoardListsPagerAdapter(
onListTitleClicked = { listId ->
inlineTitleErrorMessage = null
viewModel.startEditingList(listId)
},
onEditingTitleChanged = { title ->
inlineTitleErrorMessage = null
viewModel.updateEditingTitle(title)
},
onSubmitEditingTitle = { submitted ->
val trimmed = submitted.trim()
if (trimmed.isBlank()) {
inlineTitleErrorMessage = getString(R.string.list_title_required)
viewModel.updateEditingTitle(submitted)
render(viewModel.uiState.value)
} else {
inlineTitleErrorMessage = null
viewModel.updateEditingTitle(submitted)
viewModel.submitRenameList()
}
},
onCardClick = { card -> viewModel.onCardTapped(card.id) },
onCardLongClick = { card -> viewModel.onCardLongPressed(card.id) },
)
pager.adapter = pagerAdapter
pager.registerOnPageChangeCallback(
object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
viewModel.setCurrentPage(position)
}
},
)
}
private fun observeViewModel() {
lifecycleScope.launch {
viewModel.uiState.collect { render(it) }
}
lifecycleScope.launch {
viewModel.events.collect { event ->
when (event) {
is BoardDetailUiEvent.NavigateToCardDetail -> {
val cardTitle = viewModel.uiState.value.boardDetail
?.lists
.orEmpty()
.asSequence()
.flatMap { list -> list.cards.asSequence() }
.firstOrNull { card -> card.id == event.cardId }
?.title
.orEmpty()
.trim()
.ifBlank { getString(R.string.card_detail_fallback_title) }
openCardDetail(cardId = event.cardId, cardTitle = cardTitle)
}
is BoardDetailUiEvent.ShowServerError -> {
if (viewModel.uiState.value.editingListId != null) {
inlineTitleErrorMessage = event.message
render(viewModel.uiState.value)
} else {
MaterialAlertDialogBuilder(this@BoardDetailActivity)
.setMessage(event.message)
.setPositiveButton(R.string.ok, null)
.show()
}
}
is BoardDetailUiEvent.ShowWarning -> {
Snackbar.make(pager, event.message, Snackbar.LENGTH_LONG).show()
}
}
}
}
}
private fun render(state: BoardDetailUiState) {
testUiStateObserver?.invoke(state)
supportActionBar?.title = state.boardDetail?.title ?: intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
if (
!hasShownBlockingStartupError &&
state.boardDetail == null &&
state.fullScreenErrorMessage == BoardDetailRepository.MISSING_SESSION_MESSAGE
) {
showBlockingStartupErrorAndFinish(getString(R.string.board_detail_session_expired))
return
}
fullScreenErrorContainer.visibility = if (state.fullScreenErrorMessage != null && state.boardDetail == null) {
fullScreenErrorText.text = state.fullScreenErrorMessage
View.VISIBLE
} else {
View.GONE
}
initialProgress.visibility = if (state.isInitialLoading && state.boardDetail == null) View.VISIBLE else View.GONE
val boardLists = state.filteredBoardDetail?.lists.orEmpty()
val applyPagerState = {
pagerAdapter.submit(
lists = boardLists,
selectedCardIds = state.selectedCardIds,
editingListId = state.editingListId,
editingListTitle = state.editingListTitle,
isMutating = state.isMutating,
inlineEditErrorMessage = inlineTitleErrorMessage,
)
pager.visibility = if (boardLists.isNotEmpty()) View.VISIBLE else View.GONE
emptyBoardText.visibility = if (!state.isInitialLoading && state.fullScreenErrorMessage == null && boardLists.isEmpty()) {
View.VISIBLE
} else {
View.GONE
}
if (boardLists.isNotEmpty() && pager.currentItem != state.currentPageIndex) {
pager.setCurrentItem(state.currentPageIndex, false)
}
}
val pagerRecycler = pager.getChildAt(0) as? RecyclerView
if (pagerRecycler?.isComputingLayout == true) {
pager.post {
if (!isFinishing && !isDestroyed) {
applyPagerState()
}
}
} else {
applyPagerState()
}
renderSelectionActions(state)
invalidateOptionsMenu()
renderOpenDialogs(state)
createFab.isEnabled = !state.isMutating
createFab.visibility = if (state.selectedCardIds.isEmpty()) View.VISIBLE else View.GONE
}
private fun showBlockingStartupErrorAndFinish(message: String) {
hasShownBlockingStartupError = true
MaterialAlertDialogBuilder(this)
.setMessage(message)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ ->
finish()
}
.show()
}
private fun openCardDetail(cardId: String, cardTitle: String) {
startActivity(
Intent(this, CardDetailActivity::class.java)
.putExtra(CardDetailActivity.EXTRA_CARD_ID, cardId)
.putExtra(CardDetailActivity.EXTRA_CARD_TITLE, cardTitle),
)
}
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
if (activeMoveDialog != null) {
val lists = state.boardDetail?.lists.orEmpty()
if (lists.isEmpty()) {
activeMoveDialog.dismiss()
moveDialog = null
} else {
val selectedIndex = activeMoveDialog.listView?.checkedItemPosition
?.takeIf { it in lists.indices }
?: state.currentPageIndex.coerceIn(0, lists.lastIndex)
val targetList = lists[selectedIndex]
val targetIds = targetList.cards.map { it.id }.toSet()
val canMove = !state.isMutating && (state.selectedCardIds - targetIds).isNotEmpty()
activeMoveDialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = canMove
activeMoveDialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
if (dismissMoveDialogWhenMutationEnds && !state.isMutating) {
activeMoveDialog.dismiss()
moveDialog = null
dismissMoveDialogWhenMutationEnds = false
}
if (!state.isMutating && state.selectedCardIds.isEmpty()) {
activeMoveDialog.dismiss()
moveDialog = null
}
}
}
val activeDeleteDialog = deleteSecondConfirmationDialog
if (activeDeleteDialog != null) {
activeDeleteDialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = !state.isMutating
activeDeleteDialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
if (dismissDeleteDialogWhenMutationEnds && !state.isMutating) {
activeDeleteDialog.dismiss()
deleteSecondConfirmationDialog = null
dismissDeleteDialogWhenMutationEnds = false
}
if (!state.isMutating && state.selectedCardIds.isEmpty()) {
activeDeleteDialog.dismiss()
deleteSecondConfirmationDialog = null
}
}
}
private fun renderSelectionActions(state: BoardDetailUiState) {
// Kept for compatibility with existing call sites; menu rendering is delegated to
// framework options menu callbacks.
}
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
}
menuInflater.inflate(R.menu.menu_board_detail_main, menu)
val filterItem = menu.findItem(R.id.actionFilterByTag)
val searchItem = menu.findItem(R.id.actionSearch)
filterItem?.tooltipText = getString(R.string.filter_by_tag)
searchItem?.tooltipText = getString(R.string.search)
tintMainMenuIcon(filterItem, state.activeTagFilterIds.isNotEmpty())
tintMainMenuIcon(searchItem, state.activeTitleQuery.isNotBlank())
}
private fun handleSelectionAction(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.actionSelectAll -> {
viewModel.selectAllOnCurrentPage()
true
}
R.id.actionMoveCards -> {
showMoveCardsDialog()
true
}
R.id.actionDeleteCards -> {
showDeleteCardsDialog()
true
}
else -> false
}
}
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
if (!isActive) {
item?.icon = AppCompatResources.getDrawable(this, drawableRes)
item?.iconTintList = null
return
}
val icon = AppCompatResources.getDrawable(this, drawableRes)?.mutate() ?: return
val wrapped = DrawableCompat.wrap(icon)
val tintColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, ContextCompat.getColor(this, android.R.color.holo_blue_light))
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() {
val lists = viewModel.uiState.value.boardDetail?.lists.orEmpty()
if (lists.isEmpty()) {
return
}
val listNames = lists.map { it.title }.toTypedArray()
var selectedIndex = viewModel.uiState.value.currentPageIndex.coerceIn(0, lists.lastIndex)
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.move_cards_to_list)
.setSingleChoiceItems(listNames, selectedIndex) { _, which ->
selectedIndex = which
renderOpenDialogs(viewModel.uiState.value)
}
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.move_cards, null)
.create()
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
val currentLists = viewModel.uiState.value.boardDetail?.lists.orEmpty()
val targetId = currentLists.getOrNull(selectedIndex)?.id ?: return@setOnClickListener
dismissMoveDialogWhenMutationEnds = true
viewModel.moveSelectedCards(targetId)
}
renderOpenDialogs(viewModel.uiState.value)
}
dialog.setOnDismissListener {
if (moveDialog === dialog) {
moveDialog = null
}
dismissMoveDialogWhenMutationEnds = false
}
moveDialog = dialog
dialog.show()
}
private fun showDeleteCardsDialog() {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_cards_title)
.setMessage(R.string.delete_cards_confirmation)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.delete) { _, _ ->
val secondDialog = MaterialAlertDialogBuilder(this)
.setMessage(R.string.delete_cards_second_confirmation)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.im_sure, null)
.create()
secondDialog.setOnShowListener {
secondDialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
dismissDeleteDialogWhenMutationEnds = true
viewModel.deleteSelectedCards()
}
renderOpenDialogs(viewModel.uiState.value)
}
secondDialog.setOnDismissListener {
if (deleteSecondConfirmationDialog === secondDialog) {
deleteSecondConfirmationDialog = null
}
dismissDeleteDialogWhenMutationEnds = false
}
deleteSecondConfirmationDialog = secondDialog
secondDialog.show()
}
.show()
}
protected fun provideSessionStore(): SessionStore {
return MainActivity.dependencies.sessionStoreFactory?.invoke(this) ?: SessionPreferences(applicationContext)
}
protected fun provideApiKeyStore(): ApiKeyStore {
return MainActivity.dependencies.apiKeyStoreFactory?.invoke(this)
?: PreferencesApiKeyStore(this)
}
protected fun provideApiClient(): KanbnApiClient {
return MainActivity.dependencies.apiClientFactory?.invoke() ?: HttpKanbnApiClient()
}
companion object {
const val EXTRA_BOARD_ID = "extra_board_id"
const val EXTRA_BOARD_TITLE = "extra_board_title"
var testDataSourceFactory: ((String) -> BoardDetailDataSource)? = null
var testUiStateObserver: ((BoardDetailUiState) -> Unit)? = null
}
}

View File

@@ -0,0 +1,42 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
data class BoardDetail(
val id: String,
val title: String,
val lists: List<BoardListDetail>,
)
data class BoardListDetail(
val id: String,
val title: String,
val cards: List<BoardCardSummary>,
)
data class BoardCardSummary(
val id: String,
val title: String,
val tags: List<BoardTagSummary>,
val dueAtEpochMillis: Long?,
)
data class BoardTagSummary(
val id: String,
val name: String,
val colorHex: String,
)
data class CreatedEntityRef(
val publicId: String?,
)
sealed interface CardBatchMutationResult {
data object Success : CardBatchMutationResult
data class PartialSuccess(
val failedCardIds: Set<String>,
val message: String,
) : CardBatchMutationResult
data class Failure(
val message: String,
) : CardBatchMutationResult
}

View File

@@ -0,0 +1,343 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.LocalDate
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.LabelDetail
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
class BoardDetailRepository(
private val sessionStore: SessionStore,
private val apiKeyStore: ApiKeyStore,
private val apiClient: KanbnApiClient,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
private val labelColorCache = mutableMapOf<String, String>()
companion object {
const val MISSING_SESSION_MESSAGE = "Missing session. Please sign in again."
}
suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
val normalizedBoardId = boardId.trim()
if (normalizedBoardId.isBlank()) {
return BoardsApiResult.Failure("Board id is required")
}
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return sessionResult
}
return apiClient.getBoardDetail(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
boardId = normalizedBoardId,
).mapSuccess { detail ->
hydrateLabelColors(
detail = detail,
baseUrl = session.baseUrl,
apiKey = session.apiKey,
)
}
}
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
val normalizedListId = listId.trim()
if (normalizedListId.isBlank()) {
return BoardsApiResult.Failure("List id is required")
}
val normalizedTitle = newTitle.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
}
return apiClient.renameList(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
listId = normalizedListId,
newTitle = normalizedTitle,
)
}
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 {
val normalizedTargetListId = targetListId.trim()
if (normalizedTargetListId.isBlank()) {
return CardBatchMutationResult.Failure("Target list id is required")
}
val normalizedCardIds = normalizeCardIds(cardIds)
if (normalizedCardIds.isEmpty()) {
return CardBatchMutationResult.Failure("At least one card id is required")
}
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return CardBatchMutationResult.Failure(sessionResult.message)
}
val failuresByCardId = linkedMapOf<String, String>()
normalizedCardIds.forEach { cardId ->
when (
val result = apiClient.moveCard(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
cardId = cardId,
targetListId = normalizedTargetListId,
)
) {
is BoardsApiResult.Success -> Unit
is BoardsApiResult.Failure -> failuresByCardId[cardId] = result.message
}
}
return aggregateBatchMutationResult(
normalizedCardIds = normalizedCardIds,
failuresByCardId = failuresByCardId,
partialMessage = "Some cards could not be moved. Please try again.",
)
}
suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
val normalizedCardIds = normalizeCardIds(cardIds)
if (normalizedCardIds.isEmpty()) {
return CardBatchMutationResult.Failure("At least one card id is required")
}
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return CardBatchMutationResult.Failure(sessionResult.message)
}
val failuresByCardId = linkedMapOf<String, String>()
normalizedCardIds.forEach { cardId ->
when (val result = apiClient.deleteCard(session.baseUrl, session.apiKey, cardId)) {
is BoardsApiResult.Success -> Unit
is BoardsApiResult.Failure -> failuresByCardId[cardId] = result.message
}
}
return aggregateBatchMutationResult(
normalizedCardIds = normalizedCardIds,
failuresByCardId = failuresByCardId,
partialMessage = "Some cards could not be deleted. Please try again.",
)
}
private fun normalizeCardIds(cardIds: Collection<String>): List<String> {
return cardIds.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
}
private fun aggregateBatchMutationResult(
normalizedCardIds: List<String>,
failuresByCardId: Map<String, String>,
partialMessage: String,
): CardBatchMutationResult {
if (failuresByCardId.isEmpty()) {
return CardBatchMutationResult.Success
}
if (failuresByCardId.size == normalizedCardIds.size) {
val firstFailureMessage = normalizedCardIds
.asSequence()
.mapNotNull { failuresByCardId[it] }
.firstOrNull()
?.trim()
.orEmpty()
.ifBlank { "Unknown error" }
return CardBatchMutationResult.Failure(firstFailureMessage)
}
return CardBatchMutationResult.PartialSuccess(
failedCardIds = failuresByCardId.keys.toSet(),
message = partialMessage,
)
}
private suspend fun session(): BoardsApiResult<SessionSnapshot> {
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure(MISSING_SESSION_MESSAGE)
val apiKey = withContext(ioDispatcher) {
apiKeyStore.getApiKey(baseUrl)
}.getOrNull()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure(MISSING_SESSION_MESSAGE)
val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = baseUrl, apiKey = apiKey)) {
is BoardsApiResult.Success -> workspaceResult.value
is BoardsApiResult.Failure -> return workspaceResult
}
return BoardsApiResult.Success(
SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey, workspaceId = workspaceId),
)
}
private suspend fun resolveWorkspaceId(baseUrl: String, apiKey: String): BoardsApiResult<String> {
val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() }
if (storedWorkspaceId != null) {
return BoardsApiResult.Success(storedWorkspaceId)
}
return when (val workspacesResult = apiClient.listWorkspaces(baseUrl, apiKey)) {
is BoardsApiResult.Success -> {
val first = workspacesResult.value.firstOrNull()?.id
?: return BoardsApiResult.Failure("No workspaces available for this account.")
sessionStore.saveWorkspaceId(first)
BoardsApiResult.Success(first)
}
is BoardsApiResult.Failure -> workspacesResult
}
}
private suspend fun hydrateLabelColors(
detail: BoardDetail,
baseUrl: String,
apiKey: String,
): BoardDetail {
val missingColorIds = detail.lists
.asSequence()
.flatMap { list -> list.cards.asSequence() }
.flatMap { card -> card.tags.asSequence() }
.map { it.id.trim() }
.filter { it.isNotBlank() }
.distinct()
.filter { !labelColorCache.containsKey(it) }
.toList()
missingColorIds.forEach { labelId ->
val colorHex = when (val labelResult = apiClient.getLabelByPublicId(baseUrl, apiKey, labelId)) {
is BoardsApiResult.Success -> normalizeColorHex(labelResult.value)
is BoardsApiResult.Failure -> ""
}
if (colorHex.isNotBlank()) {
labelColorCache[labelId] = colorHex
}
}
val hydratedLists = detail.lists.map { list ->
list.copy(
cards = list.cards.map { card ->
card.copy(
tags = card.tags.map { tag ->
val cached = labelColorCache[tag.id.trim()].orEmpty()
val merged = if (cached.isNotBlank()) cached else tag.colorHex
if (merged == tag.colorHex) {
tag
} else {
tag.copy(colorHex = merged)
}
},
)
},
)
}
return if (hydratedLists == detail.lists) detail else detail.copy(lists = hydratedLists)
}
private fun normalizeColorHex(label: LabelDetail): String {
return label.colorHex.trim()
}
private suspend fun <T, R> BoardsApiResult<T>.mapSuccess(transform: suspend (T) -> R): BoardsApiResult<R> {
return when (this) {
is BoardsApiResult.Success -> BoardsApiResult.Success(transform(this.value))
is BoardsApiResult.Failure -> BoardsApiResult.Failure(this.message)
}
}
private data class SessionSnapshot(
val baseUrl: String,
val apiKey: String,
@Suppress("unused")
val workspaceId: String,
)
}

View File

@@ -0,0 +1,883 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import java.time.LocalDate
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
private const val ADD_CARD_DISABLED_MESSAGE = "Create a list first to add cards."
data class BoardDetailUiState(
val isInitialLoading: Boolean = false,
val isRefreshing: Boolean = false,
val isMutating: Boolean = false,
val boardDetail: BoardDetail? = null,
val fullScreenErrorMessage: String? = null,
val currentPageIndex: Int = 0,
val selectedCardIds: Set<String> = emptySet(),
val editingListId: String? = null,
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 {
data class NavigateToCardDetail(val cardId: String) : BoardDetailUiEvent
data class ShowServerError(val message: String) : BoardDetailUiEvent
data class ShowWarning(val message: String) : BoardDetailUiEvent
}
interface BoardDetailDataSource {
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 deleteCards(cardIds: Collection<String>): CardBatchMutationResult
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit>
}
internal class BoardDetailRepositoryDataSource(
private val repository: BoardDetailRepository,
) : BoardDetailDataSource {
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
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 {
return repository.moveCards(cardIds, targetListId)
}
override suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
return repository.deleteCards(cardIds)
}
override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
return repository.renameList(listId, newTitle)
}
}
class BoardDetailViewModel(
private val boardId: String,
private val repository: BoardDetailDataSource,
) : ViewModel() {
private val _uiState = MutableStateFlow(BoardDetailUiState())
val uiState: StateFlow<BoardDetailUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<BoardDetailUiEvent>()
val events: SharedFlow<BoardDetailUiEvent> = _events.asSharedFlow()
fun loadBoardDetail() {
fetchBoardDetail(initial = true)
}
fun retryLoad() {
fetchBoardDetail(initial = true)
}
fun refreshBoardDetail() {
fetchBoardDetail(initial = false, refresh = true)
}
fun setCurrentPage(pageIndex: Int) {
_uiState.update {
it.copy(currentPageIndex = clampPageIndex(detail = it.boardDetail, pageIndex = pageIndex))
}
}
fun selectAllOnCurrentPage() {
val current = _uiState.value
val pageCards = current.filteredBoardDetail
?.lists
?.getOrNull(current.currentPageIndex)
?.cards
.orEmpty()
.map { it.id }
.toSet()
if (pageCards.isEmpty()) {
return
}
_uiState.update {
it.copy(selectedCardIds = it.selectedCardIds + pageCards)
}
}
fun onCardLongPressed(cardId: String) {
toggleCardSelection(cardId)
}
fun onCardTapped(cardId: String) {
val hasSelection = _uiState.value.selectedCardIds.isNotEmpty()
if (hasSelection) {
toggleCardSelection(cardId)
return
}
viewModelScope.launch {
_events.emit(BoardDetailUiEvent.NavigateToCardDetail(cardId))
}
}
fun onBackPressed(): Boolean {
if (_uiState.value.selectedCardIds.isEmpty()) {
return false
}
_uiState.update { it.copy(selectedCardIds = emptySet()) }
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) {
val snapshot = _uiState.value
if (snapshot.isMutating) {
return
}
val selectedIds = snapshot.selectedCardIds
if (selectedIds.isEmpty()) {
return
}
val targetCardIds = snapshot.boardDetail
?.lists
?.firstOrNull { it.id == targetListId }
?.cards
.orEmpty()
.map { it.id }
.toSet()
val movableIds = selectedIds - targetCardIds
if (movableIds.isEmpty()) {
return
}
runMutation(selectedIds = movableIds) { ids -> repository.moveCards(ids, targetListId) }
}
fun deleteSelectedCards() {
runMutation(selectedIds = _uiState.value.selectedCardIds, mutation = repository::deleteCards)
}
fun startEditingList(listId: String) {
val list = _uiState.value.boardDetail?.lists?.firstOrNull { it.id == listId } ?: return
_uiState.update {
it.copy(
editingListId = list.id,
editingListTitle = list.title,
)
}
}
fun updateEditingTitle(title: String) {
_uiState.update { it.copy(editingListTitle = title) }
}
fun submitRenameList() {
val snapshot = _uiState.value
if (snapshot.isMutating) {
return
}
val editingListId = snapshot.editingListId ?: return
val currentList = snapshot.boardDetail?.lists?.firstOrNull { it.id == editingListId } ?: return
val trimmedTitle = snapshot.editingListTitle.trim()
if (trimmedTitle == currentList.title.trim()) {
_uiState.update { it.copy(editingListId = null, editingListTitle = "") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isMutating = true) }
when (val result = repository.renameList(editingListId, trimmedTitle)) {
is BoardsApiResult.Success -> {
_uiState.update { it.copy(editingListId = null, editingListTitle = "") }
val reloadResult = reloadDetailAndReconcile()
_uiState.update { it.copy(isMutating = false) }
if (reloadResult is BoardsApiResult.Failure) {
_events.emit(
BoardDetailUiEvent.ShowWarning(
"Changes applied, but refresh failed. Pull to refresh.",
),
)
}
}
is BoardsApiResult.Failure -> {
_uiState.update { it.copy(isMutating = false) }
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
}
}
}
}
private fun fetchBoardDetail(initial: Boolean, refresh: Boolean = false) {
if (_uiState.value.isMutating) {
return
}
viewModelScope.launch {
_uiState.update {
it.copy(
isInitialLoading = initial && it.boardDetail == null,
isRefreshing = refresh,
fullScreenErrorMessage = if (initial && it.boardDetail == null) null else it.fullScreenErrorMessage,
)
}
when (val result = repository.getBoardDetail(boardId)) {
is BoardsApiResult.Success -> {
_uiState.update {
reconcileWithNewDetail(it, result.value).copy(
isInitialLoading = false,
isRefreshing = false,
fullScreenErrorMessage = null,
)
}
}
is BoardsApiResult.Failure -> {
_uiState.update {
if (it.boardDetail == null) {
it.copy(
isInitialLoading = false,
isRefreshing = false,
fullScreenErrorMessage = result.message,
)
} else {
it.copy(
isInitialLoading = false,
isRefreshing = false,
)
}
}
if (_uiState.value.boardDetail != null) {
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
}
}
}
}
}
private fun runMutation(
selectedIds: Set<String>,
mutation: suspend (Set<String>) -> CardBatchMutationResult,
) {
val preMutation = _uiState.value
if (preMutation.isMutating) {
return
}
if (preMutation.selectedCardIds.isEmpty() || selectedIds.isEmpty()) {
return
}
viewModelScope.launch {
_uiState.update { it.copy(isMutating = true) }
when (val result = mutation(selectedIds)) {
is CardBatchMutationResult.Success -> {
_uiState.update { it.copy(selectedCardIds = emptySet()) }
val reloadResult = reloadDetailAndReconcile()
_uiState.update { it.copy(isMutating = false) }
if (reloadResult is BoardsApiResult.Failure) {
_events.emit(
BoardDetailUiEvent.ShowWarning(
"Changes applied, but refresh failed. Pull to refresh.",
),
)
}
}
is CardBatchMutationResult.PartialSuccess -> {
val reloadResult = reloadDetailAndReconcile()
if (reloadResult is BoardsApiResult.Success) {
val visibleIds = allVisibleCardIds(_uiState.value.boardDetail)
_uiState.update {
it.copy(selectedCardIds = result.failedCardIds.intersect(visibleIds))
}
_events.emit(BoardDetailUiEvent.ShowWarning(result.message))
} else {
_uiState.update {
it.copy(
selectedCardIds = preMutation.selectedCardIds,
currentPageIndex = preMutation.currentPageIndex,
)
}
_events.emit(
BoardDetailUiEvent.ShowWarning(
"Some changes were applied, but refresh failed. Pull to refresh.",
),
)
}
_uiState.update { it.copy(isMutating = false) }
}
is CardBatchMutationResult.Failure -> {
_uiState.update {
it.copy(
isMutating = false,
selectedCardIds = preMutation.selectedCardIds,
currentPageIndex = preMutation.currentPageIndex,
)
}
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
}
}
}
}
private suspend fun reloadDetailAndReconcile(): BoardsApiResult<BoardDetail> {
return when (val result = repository.getBoardDetail(boardId)) {
is BoardsApiResult.Success -> {
_uiState.update { reconcileWithNewDetail(it, result.value) }
result
}
is BoardsApiResult.Failure -> result
}
}
private fun toggleCardSelection(cardId: String) {
_uiState.update {
val next = it.selectedCardIds.toMutableSet()
if (!next.add(cardId)) {
next.remove(cardId)
}
it.copy(selectedCardIds = next)
}
}
private fun reconcileWithNewDetail(current: BoardDetailUiState, detail: BoardDetail): BoardDetailUiState {
val clampedPage = clampPageIndex(detail, current.currentPageIndex)
val visibleIds = allVisibleCardIds(detail)
val prunedSelection = current.selectedCardIds.intersect(visibleIds)
val hasEditedList = current.editingListId?.let { id -> detail.lists.any { it.id == id } } ?: false
return current.copy(
boardDetail = detail,
currentPageIndex = clampedPage,
selectedCardIds = prunedSelection,
editingListId = if (hasEditedList) current.editingListId else null,
editingListTitle = if (hasEditedList) current.editingListTitle else "",
pendingTagFilterIds = current.pendingTagFilterIds.intersect(availableTagIds(detail)),
activeTagFilterIds = current.activeTagFilterIds.intersect(availableTagIds(detail)),
)
}
private fun clampPageIndex(detail: BoardDetail?, pageIndex: Int): Int {
val lastIndex = detail?.lists?.lastIndex ?: -1
if (lastIndex < 0) {
return 0
}
return pageIndex.coerceIn(0, lastIndex)
}
private fun allVisibleCardIds(detail: BoardDetail?): Set<String> {
return detail?.lists
.orEmpty()
.flatMap { list -> list.cards }
.map { card -> card.id }
.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(
private val boardId: String,
private val repository: BoardDetailRepository,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(BoardDetailViewModel::class.java)) {
return BoardDetailViewModel(
boardId = boardId,
repository = BoardDetailRepositoryDataSource(repository),
) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}
}
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
},
)
},
)
}

View File

@@ -0,0 +1,161 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textfield.TextInputLayout
import space.hackenslacker.kanbn4droid.app.R
class BoardListsPagerAdapter(
private val onListTitleClicked: (String) -> Unit,
private val onEditingTitleChanged: (String) -> Unit,
private val onSubmitEditingTitle: (String) -> Unit,
private val onCardClick: (BoardCardSummary) -> Unit,
private val onCardLongClick: (BoardCardSummary) -> Unit,
) : RecyclerView.Adapter<BoardListsPagerAdapter.ListPageViewHolder>() {
private var lists: List<BoardListDetail> = emptyList()
private var selectedCardIds: Set<String> = emptySet()
private var editingListId: String? = null
private var editingListTitle: String = ""
private var isMutating: Boolean = false
private var inlineEditErrorMessage: String? = null
fun submit(
lists: List<BoardListDetail>,
selectedCardIds: Set<String>,
editingListId: String?,
editingListTitle: String,
isMutating: Boolean,
inlineEditErrorMessage: String?,
) {
this.lists = lists
this.selectedCardIds = selectedCardIds
this.editingListId = editingListId
this.editingListTitle = editingListTitle
this.isMutating = isMutating
this.inlineEditErrorMessage = inlineEditErrorMessage
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListPageViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_board_list_page, parent, false)
return ListPageViewHolder(view, onListTitleClicked, onEditingTitleChanged, onSubmitEditingTitle, onCardClick, onCardLongClick)
}
override fun getItemCount(): Int = lists.size
override fun onBindViewHolder(holder: ListPageViewHolder, position: Int) {
val list = lists[position]
holder.bind(
list = list,
selectedCardIds = selectedCardIds,
isEditing = list.id == editingListId,
editingTitle = editingListTitle,
isMutating = isMutating,
inlineEditErrorMessage = inlineEditErrorMessage,
)
}
class ListPageViewHolder(
itemView: View,
private val onListTitleClicked: (String) -> Unit,
private val onEditingTitleChanged: (String) -> Unit,
private val onSubmitEditingTitle: (String) -> Unit,
onCardClick: (BoardCardSummary) -> Unit,
onCardLongClick: (BoardCardSummary) -> Unit,
) : RecyclerView.ViewHolder(itemView) {
private val listTitleText: TextView = itemView.findViewById(R.id.listTitleText)
private val listTitleInputLayout: TextInputLayout = itemView.findViewById(R.id.listTitleInputLayout)
private val listTitleEditInput: EditText = itemView.findViewById(R.id.listTitleEditInput)
private val cardsRecycler: RecyclerView = itemView.findViewById(R.id.listCardsRecycler)
private val emptyText: TextView = itemView.findViewById(R.id.listEmptyText)
private val cardsAdapter = CardsAdapter(onCardClick = onCardClick, onCardLongClick = onCardLongClick)
private var isBinding = false
private var attachedListId: String? = null
init {
cardsRecycler.adapter = cardsAdapter
listTitleEditInput.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(editable: Editable?) {
if (isBinding) {
return
}
onEditingTitleChanged(editable?.toString().orEmpty())
}
})
listTitleEditInput.setOnEditorActionListener { _, actionId, event ->
val imeDone = actionId == EditorInfo.IME_ACTION_DONE
val enterKey = event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN
if (imeDone || enterKey) {
onSubmitEditingTitle(listTitleEditInput.text?.toString().orEmpty())
true
} else {
false
}
}
listTitleEditInput.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
if (isBinding || hasFocus) {
return@OnFocusChangeListener
}
if (listTitleInputLayout.visibility == View.VISIBLE) {
onSubmitEditingTitle(listTitleEditInput.text?.toString().orEmpty())
}
}
}
fun bind(
list: BoardListDetail,
selectedCardIds: Set<String>,
isEditing: Boolean,
editingTitle: String,
isMutating: Boolean,
inlineEditErrorMessage: String?,
) {
attachedListId = list.id
listTitleText.text = list.title
listTitleText.setOnClickListener { onListTitleClicked(list.id) }
cardsAdapter.submitCards(list.cards, selectedCardIds)
val hasCards = list.cards.isNotEmpty()
cardsRecycler.visibility = if (hasCards) View.VISIBLE else View.GONE
emptyText.visibility = if (hasCards) View.GONE else View.VISIBLE
isBinding = true
if (isEditing) {
listTitleText.visibility = View.GONE
listTitleInputLayout.visibility = View.VISIBLE
listTitleEditInput.isEnabled = !isMutating
if (listTitleEditInput.text?.toString() != editingTitle) {
listTitleEditInput.setText(editingTitle)
listTitleEditInput.setSelection(editingTitle.length)
}
listTitleInputLayout.error = inlineEditErrorMessage
if (!listTitleEditInput.hasFocus()) {
listTitleEditInput.requestFocus()
}
} else {
listTitleInputLayout.visibility = View.GONE
listTitleText.visibility = View.VISIBLE
listTitleInputLayout.error = null
}
isBinding = false
}
}
}

View File

@@ -0,0 +1,116 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.card.MaterialCardView
import com.google.android.material.chip.Chip
import com.google.android.material.color.MaterialColors
import space.hackenslacker.kanbn4droid.app.R
import java.text.DateFormat as JavaDateFormat
import java.util.Date
class CardsAdapter(
private val onCardClick: (BoardCardSummary) -> Unit,
private val onCardLongClick: (BoardCardSummary) -> Unit,
) : RecyclerView.Adapter<CardsAdapter.CardViewHolder>() {
private var cards: List<BoardCardSummary> = emptyList()
private var selectedCardIds: Set<String> = emptySet()
fun submitCards(cards: List<BoardCardSummary>, selectedCardIds: Set<String>) {
this.cards = cards
this.selectedCardIds = selectedCardIds
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CardViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_board_card_detail, parent, false)
return CardViewHolder(view)
}
override fun onBindViewHolder(holder: CardViewHolder, position: Int) {
holder.bind(cards[position], selectedCardIds.contains(cards[position].id), onCardClick, onCardLongClick)
}
override fun getItemCount(): Int = cards.size
class CardViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val rootCard: MaterialCardView = itemView.findViewById(R.id.cardItemRoot)
private val titleText: TextView = itemView.findViewById(R.id.cardTitleText)
private val tagsContainer: LinearLayout = itemView.findViewById(R.id.cardTagsContainer)
private val dueDateText: TextView = itemView.findViewById(R.id.cardDueDateText)
fun bind(
card: BoardCardSummary,
isSelected: Boolean,
onCardClick: (BoardCardSummary) -> Unit,
onCardLongClick: (BoardCardSummary) -> Unit,
) {
titleText.text = card.title
bindTags(card.tags)
bindDueDate(card.dueAtEpochMillis)
rootCard.isChecked = isSelected
rootCard.strokeWidth = if (isSelected) 4 else 1
val strokeColor = if (isSelected) {
MaterialColors.getColor(rootCard, com.google.android.material.R.attr.colorPrimary, Color.BLUE)
} else {
MaterialColors.getColor(rootCard, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
}
rootCard.strokeColor = strokeColor
itemView.setOnClickListener { onCardClick(card) }
itemView.setOnLongClickListener {
onCardLongClick(card)
true
}
}
private fun bindTags(tags: List<BoardTagSummary>) {
tagsContainer.removeAllViews()
tagsContainer.visibility = if (tags.isEmpty()) View.GONE else View.VISIBLE
tags.forEach { tag ->
val chip = Chip(itemView.context)
chip.text = tag.name
chip.isClickable = false
chip.isCheckable = false
chip.chipBackgroundColor = null
chip.chipStrokeWidth = 2f
chip.chipStrokeColor = android.content.res.ColorStateList.valueOf(parseColorOrFallback(tag.colorHex))
tagsContainer.addView(chip)
}
}
private fun bindDueDate(dueAtEpochMillis: Long?) {
if (dueAtEpochMillis == null) {
dueDateText.visibility = View.GONE
dueDateText.text = ""
return
}
val isExpired = dueAtEpochMillis < System.currentTimeMillis()
val color = if (isExpired) {
MaterialColors.getColor(dueDateText, com.google.android.material.R.attr.colorError, Color.RED)
} else {
MaterialColors.getColor(dueDateText, com.google.android.material.R.attr.colorOnSurface, Color.BLACK)
}
dueDateText.setTextColor(color)
val formatted = JavaDateFormat.getDateInstance(JavaDateFormat.MEDIUM, java.util.Locale.getDefault())
.format(Date(dueAtEpochMillis))
dueDateText.text = formatted
dueDateText.visibility = View.VISIBLE
}
private fun parseColorOrFallback(colorHex: String): Int {
return runCatching { Color.parseColor(colorHex) }
.getOrElse {
MaterialColors.getColor(itemView, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
}
}
}
}

View File

@@ -0,0 +1,57 @@
package space.hackenslacker.kanbn4droid.app.boards
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import space.hackenslacker.kanbn4droid.app.R
class BoardsDrawerAdapter(
private val onWorkspaceClick: (WorkspaceSummary) -> Unit,
) : RecyclerView.Adapter<BoardsDrawerAdapter.WorkspaceViewHolder>() {
private val workspaces = mutableListOf<WorkspaceSummary>()
private var activeWorkspaceId: String? = null
fun submitItems(workspaces: List<WorkspaceSummary>, activeWorkspaceId: String?) {
this.workspaces.clear()
this.workspaces.addAll(workspaces)
this.activeWorkspaceId = activeWorkspaceId
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WorkspaceViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_workspace_drawer, parent, false)
return WorkspaceViewHolder(view)
}
override fun onBindViewHolder(holder: WorkspaceViewHolder, position: Int) {
val workspace = workspaces[position]
holder.bind(
workspace = workspace,
isSelected = workspace.id == activeWorkspaceId,
onWorkspaceClick = onWorkspaceClick,
)
}
override fun getItemCount(): Int = workspaces.size
class WorkspaceViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val workspaceTitleText: TextView = itemView.findViewById(R.id.workspaceTitleText)
fun bind(
workspace: WorkspaceSummary,
isSelected: Boolean,
onWorkspaceClick: (WorkspaceSummary) -> Unit,
) {
workspaceTitleText.text = workspace.name
itemView.isSelected = isSelected
itemView.isActivated = isSelected
workspaceTitleText.isSelected = isSelected
workspaceTitleText.isActivated = isSelected
itemView.setOnClickListener {
onWorkspaceClick(workspace)
}
}
}
}

View File

@@ -0,0 +1,41 @@
package space.hackenslacker.kanbn4droid.app.boards
enum class DrawerDataErrorCode {
NONE,
UNAUTHORIZED,
NETWORK,
SERVER,
}
data class DrawerProfile(
val displayName: String,
val email: String?,
)
data class DrawerDataResult(
val profile: DrawerProfile?,
val workspaces: List<WorkspaceSummary>,
val activeWorkspaceId: String?,
val profileError: String?,
val workspacesError: String?,
val errorCode: DrawerDataErrorCode,
val didFallbackWorkspace: Boolean = false,
)
data class BoardsDrawerState(
val isLoading: Boolean = false,
val profile: DrawerProfile? = null,
val workspaces: List<WorkspaceSummary> = emptyList(),
val activeWorkspaceId: String? = null,
val profileError: String? = null,
val workspacesError: String? = null,
val errorCode: DrawerDataErrorCode = DrawerDataErrorCode.NONE,
val isRetryable: Boolean = false,
val isWorkspaceInteractionEnabled: Boolean = false,
val isWorkspaceSwitchInFlight: Boolean = false,
)
internal fun String.isUnauthorizedFailureMessage(): Boolean {
val normalized = lowercase()
return "401" in normalized || "403" in normalized || "authentication" in normalized || "unauthorized" in normalized
}

View File

@@ -13,6 +13,81 @@ class BoardsRepository(
private val apiClient: KanbnApiClient, private val apiClient: KanbnApiClient,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) { ) {
fun clearSessionCaches() {
// No-op: this repository currently keeps no in-memory session cache.
}
suspend fun loadDrawerData(): DrawerDataResult {
val session = when (val sessionResult = authSession()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> {
return DrawerDataResult(
profile = null,
workspaces = emptyList(),
activeWorkspaceId = null,
profileError = sessionResult.message,
workspacesError = sessionResult.message,
errorCode = DrawerDataErrorCode.SERVER,
)
}
}
val profileResult = apiClient.getCurrentUser(session.baseUrl, session.apiKey)
val workspacesResult = apiClient.listWorkspaces(session.baseUrl, session.apiKey)
val profile = (profileResult as? BoardsApiResult.Success)?.value
val profileError = (profileResult as? BoardsApiResult.Failure)?.message
val workspaces = (workspacesResult as? BoardsApiResult.Success)?.value.orEmpty()
val workspacesError = (workspacesResult as? BoardsApiResult.Failure)?.message
val workspaceResolution = resolveActiveWorkspaceForDrawer(
workspaces = workspaces,
workspacesError = workspacesError,
)
val activeWorkspaceId = workspaceResolution.activeWorkspaceId
val errorCode = deriveDrawerErrorCode(profileError = profileError, workspacesError = workspacesError)
return DrawerDataResult(
profile = profile,
workspaces = workspaces,
activeWorkspaceId = activeWorkspaceId,
profileError = profileError,
workspacesError = workspacesError,
errorCode = errorCode,
didFallbackWorkspace = workspaceResolution.didFallback,
)
}
suspend fun switchWorkspace(workspaceId: String): BoardsApiResult<Unit> {
val normalizedWorkspaceId = workspaceId.trim()
if (normalizedWorkspaceId.isBlank()) {
return BoardsApiResult.Failure("Workspace id is required")
}
val session = when (val sessionResult = authSession()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return sessionResult
}
val previousWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() }
sessionStore.saveWorkspaceId(normalizedWorkspaceId)
val listBoardsResult = apiClient.listBoards(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
workspaceId = normalizedWorkspaceId,
)
return when (listBoardsResult) {
is BoardsApiResult.Success -> BoardsApiResult.Success(Unit)
is BoardsApiResult.Failure -> {
if (previousWorkspaceId != null) {
sessionStore.saveWorkspaceId(previousWorkspaceId)
} else {
sessionStore.clearWorkspaceId()
}
listBoardsResult
}
}
}
suspend fun listBoards(): BoardsApiResult<List<BoardSummary>> { suspend fun listBoards(): BoardsApiResult<List<BoardSummary>> {
val session = when (val sessionResult = session()) { val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value is BoardsApiResult.Success -> sessionResult.value
@@ -66,23 +141,31 @@ class BoardsRepository(
} }
private suspend fun session(): BoardsApiResult<SessionSnapshot> { private suspend fun session(): BoardsApiResult<SessionSnapshot> {
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() } val authSession = when (val sessionResult = authSession()) {
?: return BoardsApiResult.Failure("Missing session. Please sign in again.") is BoardsApiResult.Success -> sessionResult.value
val apiKey = withContext(ioDispatcher) { is BoardsApiResult.Failure -> return sessionResult
apiKeyStore.getApiKey(baseUrl) }
}.getOrNull()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = baseUrl, apiKey = apiKey)) { val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = authSession.baseUrl, apiKey = authSession.apiKey)) {
is BoardsApiResult.Success -> workspaceResult.value is BoardsApiResult.Success -> workspaceResult.value
is BoardsApiResult.Failure -> return workspaceResult is BoardsApiResult.Failure -> return workspaceResult
} }
return BoardsApiResult.Success( return BoardsApiResult.Success(
SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey, workspaceId = workspaceId), SessionSnapshot(baseUrl = authSession.baseUrl, apiKey = authSession.apiKey, workspaceId = workspaceId),
) )
} }
private suspend fun authSession(): BoardsApiResult<AuthSessionSnapshot> {
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure(MISSING_SESSION_MESSAGE)
val apiKey = withContext(ioDispatcher) {
apiKeyStore.getApiKey(baseUrl)
}.getOrNull()?.takeIf { it.isNotBlank() }
?: return BoardsApiResult.Failure(MISSING_SESSION_MESSAGE)
return BoardsApiResult.Success(AuthSessionSnapshot(baseUrl = baseUrl, apiKey = apiKey))
}
private suspend fun resolveWorkspaceId(baseUrl: String, apiKey: String): BoardsApiResult<String> { private suspend fun resolveWorkspaceId(baseUrl: String, apiKey: String): BoardsApiResult<String> {
val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() } val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() }
if (storedWorkspaceId != null) { if (storedWorkspaceId != null) {
@@ -101,9 +184,70 @@ class BoardsRepository(
} }
} }
private fun resolveActiveWorkspaceForDrawer(
workspaces: List<WorkspaceSummary>,
workspacesError: String?,
): WorkspaceResolution {
val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() }
if (workspacesError != null) {
return WorkspaceResolution(activeWorkspaceId = storedWorkspaceId, didFallback = false)
}
if (workspaces.isEmpty()) {
if (storedWorkspaceId != null) {
sessionStore.clearWorkspaceId()
}
return WorkspaceResolution(activeWorkspaceId = null, didFallback = false)
}
if (storedWorkspaceId != null && workspaces.any { it.id == storedWorkspaceId }) {
return WorkspaceResolution(activeWorkspaceId = storedWorkspaceId, didFallback = false)
}
val fallbackWorkspaceId = workspaces.first().id
sessionStore.saveWorkspaceId(fallbackWorkspaceId)
return WorkspaceResolution(activeWorkspaceId = fallbackWorkspaceId, didFallback = true)
}
private fun deriveDrawerErrorCode(profileError: String?, workspacesError: String?): DrawerDataErrorCode {
val errors = listOfNotNull(profileError, workspacesError)
if (errors.isEmpty()) {
return DrawerDataErrorCode.NONE
}
if (errors.any { isUnauthorizedMessage(it) }) {
return DrawerDataErrorCode.UNAUTHORIZED
}
if (errors.any { isNetworkMessage(it) }) {
return DrawerDataErrorCode.NETWORK
}
return DrawerDataErrorCode.SERVER
}
private fun isUnauthorizedMessage(message: String): Boolean {
return message.isUnauthorizedFailureMessage()
}
private fun isNetworkMessage(message: String): Boolean {
val normalized = message.lowercase()
return "cannot reach server" in normalized || "connection" in normalized || "timeout" in normalized
}
private data class AuthSessionSnapshot(
val baseUrl: String,
val apiKey: String,
)
private data class WorkspaceResolution(
val activeWorkspaceId: String?,
val didFallback: Boolean,
)
private data class SessionSnapshot( private data class SessionSnapshot(
val baseUrl: String, val baseUrl: String,
val apiKey: String, val apiKey: String,
val workspaceId: String, val workspaceId: String,
) )
private companion object {
private const val MISSING_SESSION_MESSAGE = "Missing session. Please sign in again."
}
} }

View File

@@ -19,15 +19,18 @@ data class BoardsUiState(
val boards: List<BoardSummary> = emptyList(), val boards: List<BoardSummary> = emptyList(),
val templates: List<BoardTemplate> = emptyList(), val templates: List<BoardTemplate> = emptyList(),
val isTemplatesLoading: Boolean = false, val isTemplatesLoading: Boolean = false,
val drawer: BoardsDrawerState = BoardsDrawerState(),
) )
sealed interface BoardsUiEvent { interface BoardsUiEvent {
data class NavigateToBoard(val boardId: String, val boardTitle: String) : BoardsUiEvent data class NavigateToBoard(val boardId: String, val boardTitle: String) : BoardsUiEvent
data class ShowServerError(val message: String) : BoardsUiEvent data class ShowServerError(val message: String) : BoardsUiEvent
data object ForceSignOut : BoardsUiEvent
} }
class BoardsViewModel( class BoardsViewModel(
private val repository: BoardsRepository, private val repository: BoardsRepository,
private val nowProvider: () -> Long = { System.currentTimeMillis() },
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(BoardsUiState()) private val _uiState = MutableStateFlow(BoardsUiState())
val uiState: StateFlow<BoardsUiState> = _uiState.asStateFlow() val uiState: StateFlow<BoardsUiState> = _uiState.asStateFlow()
@@ -35,14 +38,112 @@ class BoardsViewModel(
private val _events = MutableSharedFlow<BoardsUiEvent>() private val _events = MutableSharedFlow<BoardsUiEvent>()
val events: SharedFlow<BoardsUiEvent> = _events.asSharedFlow() val events: SharedFlow<BoardsUiEvent> = _events.asSharedFlow()
private var lastDrawerLoadAtMillis: Long? = null
private var hadDrawerLoadFailureSinceLastSuccess: Boolean = false
fun loadBoards() { fun loadBoards() {
fetchBoards(initial = true) fetchBoards(initial = true)
} }
fun loadDrawerData() {
fetchDrawerData()
}
fun loadDrawerDataIfStale() {
if (hadDrawerLoadFailureSinceLastSuccess) {
fetchDrawerData()
return
}
val now = nowProvider()
val isStale = lastDrawerLoadAtMillis?.let { now - it >= DRAWER_STALE_MS } ?: true
if (!isStale) {
return
}
fetchDrawerData()
}
fun retryDrawerData() {
fetchDrawerData()
}
fun onWorkspaceSelected(workspaceId: String) {
val normalizedWorkspaceId = workspaceId.trim()
if (normalizedWorkspaceId.isBlank()) {
return
}
val currentDrawer = _uiState.value.drawer
if (currentDrawer.isWorkspaceSwitchInFlight) {
return
}
val currentWorkspaceId = currentDrawer.activeWorkspaceId
if (currentWorkspaceId == normalizedWorkspaceId) {
return
}
viewModelScope.launch {
_uiState.update {
it.copy(
drawer = it.drawer.copy(
activeWorkspaceId = normalizedWorkspaceId,
isWorkspaceSwitchInFlight = true,
isWorkspaceInteractionEnabled = false,
),
)
}
when (val result = repository.switchWorkspace(normalizedWorkspaceId)) {
is BoardsApiResult.Success -> {
_uiState.update {
it.copy(
drawer = it.drawer.copy(
isWorkspaceSwitchInFlight = false,
isWorkspaceInteractionEnabled = it.drawer.workspaces.isNotEmpty(),
errorCode = DrawerDataErrorCode.NONE,
),
)
}
fetchBoards(initial = false, refresh = true)
}
is BoardsApiResult.Failure -> {
_uiState.update {
it.copy(
drawer = it.drawer.copy(
activeWorkspaceId = currentWorkspaceId,
isWorkspaceSwitchInFlight = false,
isWorkspaceInteractionEnabled = it.drawer.workspaces.isNotEmpty(),
),
)
}
if (result.message.isUnauthorizedFailureMessage()) {
_events.emit(BoardsUiEvent.ForceSignOut)
} else {
_events.emit(BoardsUiEvent.ShowServerError(result.message))
}
}
}
}
}
fun refreshBoards() { fun refreshBoards() {
fetchBoards(initial = false, refresh = true) fetchBoards(initial = false, refresh = true)
} }
fun clearSessionUiState() {
repository.clearSessionCaches()
lastDrawerLoadAtMillis = null
hadDrawerLoadFailureSinceLastSuccess = false
_uiState.value = BoardsUiState()
}
fun onSettingsApplied(credentialsChanged: Boolean) {
if (!credentialsChanged) {
return
}
fetchDrawerData()
fetchBoards(initial = false, refresh = true)
}
fun loadTemplatesIfNeeded() { fun loadTemplatesIfNeeded() {
val current = _uiState.value val current = _uiState.value
if (current.templates.isNotEmpty() || current.isTemplatesLoading) { if (current.templates.isNotEmpty() || current.isTemplatesLoading) {
@@ -144,6 +245,57 @@ class BoardsViewModel(
} }
} }
private fun fetchDrawerData() {
if (_uiState.value.drawer.isLoading) {
return
}
viewModelScope.launch {
_uiState.update {
it.copy(
drawer = it.drawer.copy(isLoading = true),
)
}
val result = repository.loadDrawerData()
val interactionEnabled = result.workspacesError == null && result.workspaces.isNotEmpty() && result.activeWorkspaceId != null
val retryable = result.errorCode == DrawerDataErrorCode.NETWORK ||
(result.profileError != null || result.workspacesError != null)
_uiState.update {
it.copy(
drawer = it.drawer.copy(
isLoading = false,
profile = result.profile,
workspaces = result.workspaces,
activeWorkspaceId = result.activeWorkspaceId,
profileError = result.profileError,
workspacesError = result.workspacesError,
errorCode = result.errorCode,
isRetryable = retryable,
isWorkspaceInteractionEnabled = interactionEnabled,
),
)
}
if (result.errorCode == DrawerDataErrorCode.NONE) {
lastDrawerLoadAtMillis = nowProvider()
hadDrawerLoadFailureSinceLastSuccess = false
} else {
hadDrawerLoadFailureSinceLastSuccess = true
}
if (result.errorCode == DrawerDataErrorCode.UNAUTHORIZED) {
_events.emit(BoardsUiEvent.ForceSignOut)
return@launch
}
if (result.didFallbackWorkspace && result.activeWorkspaceId != null) {
fetchBoards(initial = false, refresh = true)
}
}
}
private suspend fun refetchBoardsAfterMutation() { private suspend fun refetchBoardsAfterMutation() {
when (val boardsResult = repository.listBoards()) { when (val boardsResult = repository.listBoards()) {
is BoardsApiResult.Success -> { is BoardsApiResult.Success -> {
@@ -175,4 +327,8 @@ class BoardsViewModel(
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
} }
} }
private companion object {
private const val DRAWER_STALE_MS = 2 * 60 * 1000L
}
} }

View File

@@ -0,0 +1,83 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import space.hackenslacker.kanbn4droid.app.R
class ActivityTimelineAdapter(
private val markdownRenderer: MarkdownRenderer,
) : RecyclerView.Adapter<ActivityTimelineAdapter.ActivityViewHolder>() {
private var items: List<CardActivity> = emptyList()
fun submitActivities(value: List<CardActivity>) {
items = value
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ActivityViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_card_activity_timeline, parent, false)
return ActivityViewHolder(view, markdownRenderer)
}
override fun onBindViewHolder(holder: ActivityViewHolder, position: Int) {
holder.bind(
activity = items[position],
isFirst = position == 0,
isLast = position == items.lastIndex,
)
}
override fun getItemCount(): Int = items.size
class ActivityViewHolder(
itemView: View,
private val markdownRenderer: MarkdownRenderer,
) : RecyclerView.ViewHolder(itemView) {
private val topConnector: View = itemView.findViewById(R.id.timelineTopConnector)
private val bottomConnector: View = itemView.findViewById(R.id.timelineBottomConnector)
private val icon: ImageView = itemView.findViewById(R.id.timelineIcon)
private val header: TextView = itemView.findViewById(R.id.timelineHeaderText)
private val body: TextView = itemView.findViewById(R.id.timelineBodyText)
fun bind(activity: CardActivity, isFirst: Boolean, isLast: Boolean) {
topConnector.visibility = if (isFirst) View.INVISIBLE else View.VISIBLE
bottomConnector.visibility = if (isLast) View.INVISIBLE else View.VISIBLE
icon.setImageResource(R.drawable.ic_timeline_note_24)
val action = when {
activity.type.contains("comment", ignoreCase = true) -> itemView.context.getString(R.string.card_detail_timeline_action_commented)
else -> itemView.context.getString(R.string.card_detail_timeline_action_updated)
}
val actor = itemView.context.getString(R.string.card_detail_timeline_actor_unknown)
val relative = DateUtils.getRelativeTimeSpanString(
activity.createdAtEpochMillis,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
)
header.text = itemView.context.getString(
R.string.card_detail_timeline_header,
actor,
action,
relative,
)
val isComment = activity.type.contains("comment", ignoreCase = true)
val text = activity.text.trim()
if (isComment && text.isNotBlank()) {
body.visibility = View.VISIBLE
body.text = markdownRenderer.render(text)
MarkdownRenderer.enableLinks(body)
} else {
body.visibility = View.GONE
body.text = ""
}
}
}
}

View File

@@ -0,0 +1,599 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import android.app.DatePickerDialog
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.button.MaterialButton
import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import java.text.DateFormat
import java.time.LocalDate
import java.time.ZoneId
import java.util.Date
import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.MainActivity
import space.hackenslacker.kanbn4droid.app.R
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.PreferencesApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
class CardDetailActivity : AppCompatActivity() {
private lateinit var cardId: String
private lateinit var sessionStore: SessionStore
private lateinit var apiKeyStore: ApiKeyStore
private lateinit var apiClient: KanbnApiClient
private lateinit var toolbar: MaterialToolbar
private lateinit var initialProgress: ProgressBar
private lateinit var errorContainer: View
private lateinit var errorText: TextView
private lateinit var retryButton: Button
private lateinit var contentScroll: View
private lateinit var titleInputLayout: TextInputLayout
private lateinit var titleInput: TextInputEditText
private lateinit var titleSavingText: TextView
private lateinit var titleErrorText: TextView
private lateinit var titleRetryButton: MaterialButton
private lateinit var tagsRecycler: androidx.recyclerview.widget.RecyclerView
private lateinit var dueDateText: TextView
private lateinit var dueDateClearButton: MaterialButton
private lateinit var dueDateSavingText: TextView
private lateinit var dueDateErrorText: TextView
private lateinit var dueDateRetryButton: MaterialButton
private lateinit var descriptionModeToggle: MaterialButtonToggleGroup
private lateinit var descriptionEditButton: MaterialButton
private lateinit var descriptionPreviewButton: MaterialButton
private lateinit var descriptionInputLayout: TextInputLayout
private lateinit var descriptionInput: TextInputEditText
private lateinit var descriptionPreviewText: TextView
private lateinit var descriptionSavingText: TextView
private lateinit var descriptionErrorText: TextView
private lateinit var descriptionRetryButton: MaterialButton
private lateinit var timelineProgress: ProgressBar
private lateinit var timelineErrorText: TextView
private lateinit var timelineRetryButton: MaterialButton
private lateinit var timelineEmptyText: TextView
private lateinit var timelineRecycler: androidx.recyclerview.widget.RecyclerView
private lateinit var addCommentFab: FloatingActionButton
private val markdownRenderer = MarkdownRenderer()
private lateinit var tagAdapter: CardDetailTagChipAdapter
private lateinit var timelineAdapter: ActivityTimelineAdapter
private var commentDialog: AlertDialog? = null
private var hasShownSessionDialog: Boolean = false
private var suppressTitleChange = false
private var suppressDescriptionChange = false
private var suppressCommentChange = false
private val viewModel: CardDetailViewModel by viewModels {
val id = cardId
val fakeFactory = testDataSourceFactory
if (fakeFactory != null) {
CardDetailViewModel.Factory(
cardId = id,
dataSource = fakeFactory.invoke(id),
titleRequiredMessage = getString(R.string.card_title_required),
commentRequiredMessage = getString(R.string.card_detail_comment_required),
commentAddedMessage = getString(R.string.card_detail_comment_added),
)
} else {
val repository = CardDetailRepository(
sessionStore = sessionStore,
apiKeyStore = apiKeyStore,
apiClient = apiClient,
)
CardDetailViewModel.Factory(
cardId = id,
dataSource = CardDetailRepositoryDataSource(repository, repository::loadCard),
titleRequiredMessage = getString(R.string.card_title_required),
commentRequiredMessage = getString(R.string.card_detail_comment_required),
commentAddedMessage = getString(R.string.card_detail_comment_added),
)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
cardId = intent.getStringExtra(EXTRA_CARD_ID).orEmpty()
sessionStore = provideSessionStore()
apiKeyStore = provideApiKeyStore()
apiClient = provideApiClient()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_card_detail)
bindViews()
setupToolbar()
setupAdapters()
setupInputs()
observeViewModel()
if (cardId.isBlank()) {
showBlockingDialogAndFinish(getString(R.string.card_detail_unable_to_open_card))
return
}
viewModel.load()
}
override fun onStop() {
super.onStop()
viewModel.onStop()
}
private fun bindViews() {
toolbar = findViewById(R.id.cardDetailToolbar)
initialProgress = findViewById(R.id.cardDetailInitialProgress)
errorContainer = findViewById(R.id.cardDetailErrorContainer)
errorText = findViewById(R.id.cardDetailErrorText)
retryButton = findViewById(R.id.cardDetailRetryButton)
contentScroll = findViewById(R.id.cardDetailContentScroll)
titleInputLayout = findViewById(R.id.cardDetailTitleInputLayout)
titleInput = findViewById(R.id.cardDetailTitleInput)
titleSavingText = findViewById(R.id.cardDetailTitleSavingText)
titleErrorText = findViewById(R.id.cardDetailTitleErrorText)
titleRetryButton = findViewById(R.id.cardDetailTitleRetryButton)
tagsRecycler = findViewById(R.id.cardDetailTagsRecycler)
dueDateText = findViewById(R.id.cardDetailDueDateText)
dueDateClearButton = findViewById(R.id.cardDetailDueDateClearButton)
dueDateSavingText = findViewById(R.id.cardDetailDueDateSavingText)
dueDateErrorText = findViewById(R.id.cardDetailDueDateErrorText)
dueDateRetryButton = findViewById(R.id.cardDetailDueDateRetryButton)
descriptionModeToggle = findViewById(R.id.cardDetailDescriptionModeToggle)
descriptionEditButton = findViewById(R.id.cardDetailDescriptionEditButton)
descriptionPreviewButton = findViewById(R.id.cardDetailDescriptionPreviewButton)
descriptionInputLayout = findViewById(R.id.cardDetailDescriptionInputLayout)
descriptionInput = findViewById(R.id.cardDetailDescriptionInput)
descriptionPreviewText = findViewById(R.id.cardDetailDescriptionPreviewText)
descriptionSavingText = findViewById(R.id.cardDetailDescriptionSavingText)
descriptionErrorText = findViewById(R.id.cardDetailDescriptionErrorText)
descriptionRetryButton = findViewById(R.id.cardDetailDescriptionRetryButton)
timelineProgress = findViewById(R.id.cardDetailTimelineProgress)
timelineErrorText = findViewById(R.id.cardDetailTimelineErrorText)
timelineRetryButton = findViewById(R.id.cardDetailTimelineRetryButton)
timelineEmptyText = findViewById(R.id.cardDetailTimelineEmptyText)
timelineRecycler = findViewById(R.id.cardDetailTimelineRecycler)
addCommentFab = findViewById(R.id.cardDetailAddCommentFab)
}
private fun setupToolbar() {
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = intent.getStringExtra(EXTRA_CARD_TITLE)
?.trim()
.orEmpty()
.ifBlank { getString(R.string.card_detail_title_default) }
toolbar.setNavigationOnClickListener { finish() }
retryButton.setOnClickListener { viewModel.retryLoad() }
}
private fun setupAdapters() {
tagAdapter = CardDetailTagChipAdapter()
tagsRecycler.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
tagsRecycler.adapter = tagAdapter
timelineAdapter = ActivityTimelineAdapter(markdownRenderer)
timelineRecycler.layoutManager = LinearLayoutManager(this)
timelineRecycler.adapter = timelineAdapter
}
private fun setupInputs() {
titleInput.doAfterTextChanged {
if (!suppressTitleChange) {
viewModel.onTitleChanged(it?.toString().orEmpty())
}
}
titleInput.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
viewModel.onTitleFocusLost()
true
} else {
false
}
}
titleInput.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
viewModel.onTitleFocusLost()
}
}
titleRetryButton.setOnClickListener { viewModel.retryTitleSave() }
dueDateText.setOnClickListener {
if (!viewModel.uiState.value.isDueDateSaving) {
openDatePicker(viewModel.uiState.value.dueDate)
}
}
dueDateClearButton.setOnClickListener { viewModel.clearDueDate() }
dueDateRetryButton.setOnClickListener { viewModel.retryDueDateSave() }
descriptionInput.doAfterTextChanged {
if (!suppressDescriptionChange) {
viewModel.onDescriptionChanged(it?.toString().orEmpty())
}
}
descriptionInput.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
viewModel.onDescriptionFocusLost()
}
}
descriptionModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) {
return@addOnButtonCheckedListener
}
when (checkedId) {
R.id.cardDetailDescriptionEditButton -> viewModel.setDescriptionMode(DescriptionMode.EDIT)
R.id.cardDetailDescriptionPreviewButton -> viewModel.setDescriptionMode(DescriptionMode.PREVIEW)
}
}
descriptionRetryButton.setOnClickListener { viewModel.retryDescriptionSave() }
timelineRetryButton.setOnClickListener { viewModel.retryActivities() }
addCommentFab.setOnClickListener { viewModel.openCommentDialog() }
}
private fun observeViewModel() {
lifecycleScope.launch {
viewModel.uiState.collect { state ->
testUiStateObserver?.invoke(state)
render(state)
}
}
lifecycleScope.launch {
viewModel.events.collect { event ->
testEventObserver?.invoke(event)
when (event) {
is CardDetailUiEvent.ShowSnackbar -> {
Snackbar.make(findViewById(android.R.id.content), event.message, Snackbar.LENGTH_LONG).show()
}
CardDetailUiEvent.SessionExpired -> {
showSessionExpiredAndExit()
}
}
}
}
}
private fun render(state: CardDetailUiState) {
val showLoading = state.isInitialLoading
val showError = !showLoading && state.loadErrorMessage != null
val showContent = !showLoading && !showError
initialProgress.visibility = if (showLoading) View.VISIBLE else View.GONE
errorContainer.visibility = if (showError) View.VISIBLE else View.GONE
contentScroll.visibility = if (showContent) View.VISIBLE else View.GONE
if (showError) {
errorText.text = state.loadErrorMessage
}
renderTitle(state)
renderTags(state)
renderDueDate(state)
renderDescription(state)
renderTimeline(state)
renderCommentDialog(state)
addCommentFab.isEnabled = !state.isInitialLoading
}
private fun renderTitle(state: CardDetailUiState) {
val current = titleInput.text?.toString().orEmpty()
if (current != state.title) {
suppressTitleChange = true
titleInput.setText(state.title)
titleInput.setSelection(state.title.length)
suppressTitleChange = false
}
supportActionBar?.title = state.title.trim().ifBlank {
intent.getStringExtra(EXTRA_CARD_TITLE)
?.trim()
.orEmpty()
.ifBlank { getString(R.string.card_detail_title_default) }
}
titleInput.isEnabled = !state.isTitleSaving
titleSavingText.visibility = if (state.isTitleSaving) View.VISIBLE else View.GONE
titleInputLayout.error = null
if (state.titleErrorMessage.isNullOrBlank()) {
titleErrorText.visibility = View.GONE
titleRetryButton.visibility = View.GONE
} else {
titleErrorText.text = state.titleErrorMessage
titleErrorText.visibility = View.VISIBLE
titleRetryButton.visibility = View.VISIBLE
}
}
private fun renderTags(state: CardDetailUiState) {
tagAdapter.submitTags(state.tags)
tagsRecycler.visibility = if (state.tags.isEmpty()) View.GONE else View.VISIBLE
}
private fun renderDueDate(state: CardDetailUiState) {
val dueDate = state.dueDate
if (dueDate == null) {
dueDateText.text = getString(R.string.card_detail_set_due_date)
dueDateText.setTextColor(
MaterialColors.getColor(dueDateText, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.DKGRAY),
)
dueDateClearButton.visibility = View.GONE
} else {
dueDateText.text = formatLocalDate(dueDate)
val expired = dueDate.isBefore(LocalDate.now())
val color = if (expired) {
MaterialColors.getColor(dueDateText, com.google.android.material.R.attr.colorError, Color.RED)
} else {
MaterialColors.getColor(dueDateText, com.google.android.material.R.attr.colorOnSurface, Color.BLACK)
}
dueDateText.setTextColor(color)
dueDateClearButton.visibility = View.VISIBLE
}
dueDateText.isEnabled = !state.isDueDateSaving
dueDateClearButton.isEnabled = !state.isDueDateSaving
dueDateSavingText.visibility = if (state.isDueDateSaving) View.VISIBLE else View.GONE
if (state.dueDateErrorMessage.isNullOrBlank()) {
dueDateErrorText.visibility = View.GONE
dueDateRetryButton.visibility = View.GONE
} else {
dueDateErrorText.text = state.dueDateErrorMessage
dueDateErrorText.visibility = View.VISIBLE
dueDateRetryButton.visibility = View.VISIBLE
}
}
private fun renderDescription(state: CardDetailUiState) {
val current = descriptionInput.text?.toString().orEmpty()
if (current != state.description) {
suppressDescriptionChange = true
descriptionInput.setText(state.description)
descriptionInput.setSelection(state.description.length)
suppressDescriptionChange = false
}
when (state.descriptionMode) {
DescriptionMode.EDIT -> {
descriptionModeToggle.check(R.id.cardDetailDescriptionEditButton)
descriptionInputLayout.visibility = View.VISIBLE
descriptionPreviewText.visibility = View.GONE
}
DescriptionMode.PREVIEW -> {
descriptionModeToggle.check(R.id.cardDetailDescriptionPreviewButton)
descriptionInputLayout.visibility = View.GONE
descriptionPreviewText.visibility = View.VISIBLE
descriptionPreviewText.text = markdownRenderer.render(state.description)
MarkdownRenderer.enableLinks(descriptionPreviewText)
}
}
descriptionInput.isEnabled = !state.isDescriptionSaving
descriptionEditButton.isEnabled = true
descriptionPreviewButton.isEnabled = true
descriptionSavingText.visibility = if (state.isDescriptionSaving) View.VISIBLE else View.GONE
if (state.descriptionErrorMessage.isNullOrBlank()) {
descriptionErrorText.visibility = View.GONE
descriptionRetryButton.visibility = View.GONE
} else {
descriptionErrorText.text = state.descriptionErrorMessage
descriptionErrorText.visibility = View.VISIBLE
descriptionRetryButton.visibility = View.VISIBLE
}
}
private fun renderTimeline(state: CardDetailUiState) {
timelineProgress.visibility = if (state.isActivitiesLoading) View.VISIBLE else View.GONE
if (state.activitiesErrorMessage.isNullOrBlank()) {
timelineErrorText.visibility = View.GONE
timelineRetryButton.visibility = View.GONE
} else {
timelineErrorText.text = state.activitiesErrorMessage
timelineErrorText.visibility = View.VISIBLE
timelineRetryButton.visibility = View.VISIBLE
}
timelineAdapter.submitActivities(state.activities)
timelineRecycler.visibility = if (state.activities.isEmpty()) View.GONE else View.VISIBLE
timelineEmptyText.visibility = if (!state.isActivitiesLoading && state.activities.isEmpty() && state.activitiesErrorMessage.isNullOrBlank()) {
View.VISIBLE
} else {
View.GONE
}
}
private fun renderCommentDialog(state: CardDetailUiState) {
if (state.isCommentDialogOpen && commentDialog == null) {
showCommentDialog(state)
}
if (!state.isCommentDialogOpen) {
commentDialog?.dismiss()
commentDialog = null
return
}
commentDialog?.let { dialog ->
val toggle = dialog.findViewById<MaterialButtonToggleGroup>(R.id.commentDialogModeToggle)
val inputLayout = dialog.findViewById<TextInputLayout>(R.id.commentDialogInputLayout)
val input = dialog.findViewById<TextInputEditText>(R.id.commentDialogInput)
val preview = dialog.findViewById<TextView>(R.id.commentDialogPreviewText)
val error = dialog.findViewById<TextView>(R.id.commentDialogErrorText)
val addButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
val cancelButton = dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
input?.let {
val current = it.text?.toString().orEmpty()
if (current != state.commentDraft) {
suppressCommentChange = true
it.setText(state.commentDraft)
it.setSelection(state.commentDraft.length)
suppressCommentChange = false
}
}
if (state.commentDialogMode == CommentDialogMode.EDIT) {
toggle?.check(R.id.commentDialogEditButton)
inputLayout?.visibility = View.VISIBLE
preview?.visibility = View.GONE
} else {
toggle?.check(R.id.commentDialogPreviewButton)
inputLayout?.visibility = View.GONE
preview?.visibility = View.VISIBLE
preview?.text = markdownRenderer.render(state.commentDraft)
preview?.let { MarkdownRenderer.enableLinks(it) }
}
if (state.commentErrorMessage.isNullOrBlank()) {
error?.visibility = View.GONE
} else {
error?.text = state.commentErrorMessage
error?.visibility = View.VISIBLE
}
input?.isEnabled = !state.isCommentSubmitting
addButton?.isEnabled = !state.isCommentSubmitting
cancelButton?.isEnabled = !state.isCommentSubmitting
}
}
private fun showCommentDialog(initialState: CardDetailUiState) {
val view = LayoutInflater.from(this).inflate(R.layout.dialog_add_comment, null)
val toggle: MaterialButtonToggleGroup = view.findViewById(R.id.commentDialogModeToggle)
val input: TextInputEditText = view.findViewById(R.id.commentDialogInput)
input.setText(initialState.commentDraft)
input.doAfterTextChanged {
if (!suppressCommentChange) {
viewModel.onCommentChanged(it?.toString().orEmpty())
}
}
toggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) {
return@addOnButtonCheckedListener
}
when (checkedId) {
R.id.commentDialogEditButton -> viewModel.setCommentDialogMode(CommentDialogMode.EDIT)
R.id.commentDialogPreviewButton -> viewModel.setCommentDialogMode(CommentDialogMode.PREVIEW)
}
}
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.card_detail_add_comment)
.setView(view)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.add) { _, _ -> }
.create()
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
viewModel.submitComment()
}
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setOnClickListener {
viewModel.closeCommentDialog()
}
}
dialog.setOnDismissListener {
if (commentDialog === dialog) {
commentDialog = null
if (viewModel.uiState.value.isCommentDialogOpen) {
viewModel.closeCommentDialog()
}
}
}
commentDialog = dialog
dialog.show()
}
private fun openDatePicker(seed: LocalDate?) {
val date = seed ?: LocalDate.now()
DatePickerDialog(
this,
{ _, year, month, dayOfMonth ->
viewModel.setDueDate(LocalDate.of(year, month + 1, dayOfMonth))
},
date.year,
date.monthValue - 1,
date.dayOfMonth,
).show()
}
private fun showBlockingDialogAndFinish(message: String) {
MaterialAlertDialogBuilder(this)
.setMessage(message)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ -> finish() }
.show()
}
private fun showSessionExpiredAndExit() {
if (hasShownSessionDialog) {
return
}
hasShownSessionDialog = true
MaterialAlertDialogBuilder(this)
.setMessage(R.string.card_detail_session_expired)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ ->
val intent = Intent(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(intent)
finish()
}
.show()
}
private fun formatLocalDate(date: LocalDate): String {
val epoch = date.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
return DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(epoch))
}
protected fun provideSessionStore(): SessionStore {
return MainActivity.dependencies.sessionStoreFactory?.invoke(this) ?: SessionPreferences(applicationContext)
}
protected fun provideApiKeyStore(): ApiKeyStore {
return MainActivity.dependencies.apiKeyStoreFactory?.invoke(this)
?: PreferencesApiKeyStore(this)
}
protected fun provideApiClient(): KanbnApiClient {
return MainActivity.dependencies.apiClientFactory?.invoke() ?: HttpKanbnApiClient()
}
companion object {
const val EXTRA_CARD_ID = "extra_card_id"
const val EXTRA_CARD_TITLE = "extra_card_title"
var testDataSourceFactory: ((String) -> CardDetailDataSource)? = null
var testUiStateObserver: ((CardDetailUiState) -> Unit)? = null
var testEventObserver: ((CardDetailUiEvent) -> Unit)? = null
}
}

View File

@@ -0,0 +1,36 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import java.time.LocalDate
data class CardDetail(
val id: String,
val title: String,
val description: String,
val dueDate: LocalDate?,
val listPublicId: String?,
val index: Int?,
val tags: List<CardDetailTag>,
)
data class CardDetailTag(
val id: String,
val name: String,
val colorHex: String,
)
data class CardActivity(
val id: String,
val type: String,
val text: String,
val createdAtEpochMillis: Long,
)
enum class DescriptionMode {
EDIT,
PREVIEW,
}
enum class CommentDialogMode {
EDIT,
PREVIEW,
}

View File

@@ -0,0 +1,286 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.LocalDate
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
class CardDetailRepository(
private val sessionStore: SessionStore,
private val apiKeyStore: ApiKeyStore,
private val apiClient: KanbnApiClient,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
companion object {
const val MISSING_SESSION_MESSAGE = "Missing session. Please sign in again."
private const val SESSION_EXPIRED_MESSAGE = "Session expired. Please sign in again."
private val AUTH_STATUS_CODE_REGEX = Regex("\\b(401|403)\\b")
}
sealed interface Result<out T> {
data class Success<T>(val value: T) : Result<T>
sealed interface Failure : Result<Nothing> {
val message: String
data class Generic(override val message: String) : Failure
data class SessionExpired(override val message: String = SESSION_EXPIRED_MESSAGE) : Failure
}
}
suspend fun loadCard(cardId: String): Result<CardDetail> {
val normalizedCardId = cardId.trim()
if (normalizedCardId.isBlank()) {
return Result.Failure.Generic("Card id is required")
}
val session = when (val sessionResult = session()) {
is Result.Success -> sessionResult.value
is Result.Failure -> return sessionResult
}
return when (
val detailResult = apiClient.getCardDetail(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
cardId = normalizedCardId,
)
) {
is BoardsApiResult.Success -> Result.Success(detailResult.value)
is BoardsApiResult.Failure -> mapFailure(detailResult.message)
}
}
suspend fun updateTitle(cardId: String, title: String): Result<Unit> {
val normalizedCardId = cardId.trim()
if (normalizedCardId.isBlank()) {
return Result.Failure.Generic("Card id is required")
}
val normalizedTitle = title.trim()
if (normalizedTitle.isBlank()) {
return Result.Failure.Generic("Card title is required")
}
val session = when (val sessionResult = session()) {
is Result.Success -> sessionResult.value
is Result.Failure -> return sessionResult
}
val detail = when (
val detailResult = apiClient.getCardDetail(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
cardId = normalizedCardId,
)
) {
is BoardsApiResult.Success -> detailResult.value
is BoardsApiResult.Failure -> return mapFailure(detailResult.message)
}
return when (
val updateResult = apiClient.updateCard(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
cardId = normalizedCardId,
title = normalizedTitle,
description = detail.description,
dueDate = detail.dueDate,
)
) {
is BoardsApiResult.Success -> Result.Success(Unit)
is BoardsApiResult.Failure -> mapFailure(updateResult.message)
}
}
suspend fun updateDescription(cardId: String, description: String?): Result<Unit> {
val normalizedCardId = cardId.trim()
if (normalizedCardId.isBlank()) {
return Result.Failure.Generic("Card id is required")
}
val normalizedDescription = description?.trim()?.takeIf { it.isNotBlank() }
val session = when (val sessionResult = session()) {
is Result.Success -> sessionResult.value
is Result.Failure -> return sessionResult
}
val detail = when (
val detailResult = apiClient.getCardDetail(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
cardId = normalizedCardId,
)
) {
is BoardsApiResult.Success -> detailResult.value
is BoardsApiResult.Failure -> return mapFailure(detailResult.message)
}
return when (
val updateResult = apiClient.updateCard(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
cardId = normalizedCardId,
title = detail.title,
description = normalizedDescription ?: "",
dueDate = detail.dueDate,
)
) {
is BoardsApiResult.Success -> Result.Success(Unit)
is BoardsApiResult.Failure -> mapFailure(updateResult.message)
}
}
suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): Result<Unit> {
val normalizedCardId = cardId.trim()
if (normalizedCardId.isBlank()) {
return Result.Failure.Generic("Card id is required")
}
val session = when (val sessionResult = session()) {
is Result.Success -> sessionResult.value
is Result.Failure -> return sessionResult
}
val detail = when (
val detailResult = apiClient.getCardDetail(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
cardId = normalizedCardId,
)
) {
is BoardsApiResult.Success -> detailResult.value
is BoardsApiResult.Failure -> return mapFailure(detailResult.message)
}
return when (
val updateResult = apiClient.updateCard(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
cardId = normalizedCardId,
title = detail.title,
description = detail.description,
dueDate = dueDate,
)
) {
is BoardsApiResult.Success -> Result.Success(Unit)
is BoardsApiResult.Failure -> mapFailure(updateResult.message)
}
}
suspend fun listActivities(cardId: String): Result<List<CardActivity>> {
val normalizedCardId = cardId.trim()
if (normalizedCardId.isBlank()) {
return Result.Failure.Generic("Card id is required")
}
val session = when (val sessionResult = session()) {
is Result.Success -> sessionResult.value
is Result.Failure -> return sessionResult
}
return when (
val activitiesResult = apiClient.listCardActivities(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
cardId = normalizedCardId,
)
) {
is BoardsApiResult.Success -> Result.Success(
activitiesResult.value
.sortedByDescending { it.createdAtEpochMillis }
.take(10),
)
is BoardsApiResult.Failure -> mapFailure(activitiesResult.message)
}
}
suspend fun addComment(cardId: String, comment: String): Result<List<CardActivity>> {
val normalizedCardId = cardId.trim()
if (normalizedCardId.isBlank()) {
return Result.Failure.Generic("Card id is required")
}
val normalizedComment = comment.trim()
if (normalizedComment.isBlank()) {
return Result.Failure.Generic("Comment is required")
}
val preAddSnapshot = when (val preAddActivitiesResult = listActivities(normalizedCardId)) {
is Result.Success -> preAddActivitiesResult.value
is Result.Failure.SessionExpired -> return preAddActivitiesResult
is Result.Failure.Generic -> emptyList()
}
val session = when (val sessionResult = session()) {
is Result.Success -> sessionResult.value
is Result.Failure -> return sessionResult
}
when (
val addCommentResult = apiClient.addCardComment(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
cardId = normalizedCardId,
comment = normalizedComment,
)
) {
is BoardsApiResult.Success -> Unit
is BoardsApiResult.Failure -> return mapFailure(addCommentResult.message)
}
return when (val refreshResult = listActivities(normalizedCardId)) {
is Result.Success -> refreshResult
is Result.Failure.SessionExpired -> refreshResult
is Result.Failure.Generic -> Result.Success(preAddSnapshot)
}
}
private suspend fun session(): Result<SessionSnapshot> {
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() }
?: return Result.Failure.SessionExpired(MISSING_SESSION_MESSAGE)
val apiKey = withContext(ioDispatcher) {
apiKeyStore.getApiKey(baseUrl)
}.getOrNull()?.takeIf { it.isNotBlank() }
?: return Result.Failure.SessionExpired(MISSING_SESSION_MESSAGE)
return Result.Success(SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey))
}
private fun mapFailure(message: String): Result.Failure {
val normalizedMessage = message.trim().ifBlank { "Unknown error" }
return if (isAuthFailure(normalizedMessage)) {
Result.Failure.SessionExpired()
} else {
Result.Failure.Generic(normalizedMessage)
}
}
private fun isAuthFailure(message: String): Boolean {
val lower = message.lowercase()
val hasAuthToken =
lower.contains("authentication failed") ||
lower.contains("unauthorized") ||
lower.contains("forbidden")
val hasAuthStatusCode = AUTH_STATUS_CODE_REGEX.containsMatchIn(lower) &&
(
lower.contains("server error") ||
lower.contains("http") ||
lower.contains("status") ||
lower.contains("code")
)
return hasAuthToken || hasAuthStatusCode
}
private data class SessionSnapshot(
val baseUrl: String,
val apiKey: String,
)
}

View File

@@ -0,0 +1,56 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import android.content.res.ColorStateList
import android.graphics.Color
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.chip.Chip
import com.google.android.material.color.MaterialColors
class CardDetailTagChipAdapter : RecyclerView.Adapter<CardDetailTagChipAdapter.TagViewHolder>() {
private var tags: List<CardDetailTag> = emptyList()
fun submitTags(value: List<CardDetailTag>) {
tags = value
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TagViewHolder {
return TagViewHolder(
Chip(parent.context).apply {
isClickable = false
isCheckable = false
chipBackgroundColor = null
chipStrokeWidth = 2f
val margin = (8 * parent.context.resources.displayMetrics.density).toInt()
layoutParams = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply {
marginEnd = margin
}
},
)
}
override fun onBindViewHolder(holder: TagViewHolder, position: Int) {
holder.bind(tags[position])
}
override fun getItemCount(): Int = tags.size
class TagViewHolder(private val chip: Chip) : RecyclerView.ViewHolder(chip) {
fun bind(tag: CardDetailTag) {
chip.text = tag.name
chip.chipStrokeColor = ColorStateList.valueOf(parseColorOrFallback(tag.colorHex))
}
private fun parseColorOrFallback(value: String): Int {
return runCatching { Color.parseColor(value) }
.getOrElse {
MaterialColors.getColor(chip, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
}
}
}
}

View File

@@ -0,0 +1,601 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import java.time.LocalDate
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class CardDetailUiState(
val isInitialLoading: Boolean = false,
val loadErrorMessage: String? = null,
val isSessionExpired: Boolean = false,
val title: String = "",
val titleErrorMessage: String? = null,
val isTitleSaving: Boolean = false,
val description: String = "",
val descriptionErrorMessage: String? = null,
val isDescriptionSaving: Boolean = false,
val isDescriptionDirty: Boolean = false,
val descriptionMode: DescriptionMode = DescriptionMode.EDIT,
val dueDate: LocalDate? = null,
val dueDateErrorMessage: String? = null,
val isDueDateSaving: Boolean = false,
val tags: List<CardDetailTag> = emptyList(),
val activities: List<CardActivity> = emptyList(),
val isActivitiesLoading: Boolean = false,
val activitiesErrorMessage: String? = null,
val isCommentDialogOpen: Boolean = false,
val commentDialogMode: CommentDialogMode = CommentDialogMode.EDIT,
val commentDraft: String = "",
val isCommentSubmitting: Boolean = false,
val commentErrorMessage: String? = null,
)
sealed interface CardDetailUiEvent {
data object SessionExpired : CardDetailUiEvent
data class ShowSnackbar(val message: String) : CardDetailUiEvent
}
sealed interface DataSourceResult<out T> {
data class Success<T>(val value: T) : DataSourceResult<T>
data class GenericError(val message: String) : DataSourceResult<Nothing>
data object SessionExpired : DataSourceResult<Nothing>
}
interface CardDetailDataSource {
suspend fun loadCard(cardId: String): DataSourceResult<CardDetail>
suspend fun updateTitle(cardId: String, title: String): DataSourceResult<Unit>
suspend fun updateDescription(cardId: String, description: String): DataSourceResult<Unit>
suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult<Unit>
suspend fun listActivities(cardId: String): DataSourceResult<List<CardActivity>>
suspend fun addComment(cardId: String, comment: String): DataSourceResult<List<CardActivity>>
}
internal class CardDetailRepositoryDataSource(
private val repository: CardDetailRepository,
private val loadCardCall: suspend (String) -> CardDetailRepository.Result<CardDetail>,
) : CardDetailDataSource {
override suspend fun loadCard(cardId: String): DataSourceResult<CardDetail> {
return loadCardCall(cardId).toDataSourceResult()
}
override suspend fun updateTitle(cardId: String, title: String): DataSourceResult<Unit> {
return repository.updateTitle(cardId, title).toDataSourceResult()
}
override suspend fun updateDescription(cardId: String, description: String): DataSourceResult<Unit> {
return repository.updateDescription(cardId, description).toDataSourceResult()
}
override suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult<Unit> {
return repository.updateDueDate(cardId, dueDate).toDataSourceResult()
}
override suspend fun listActivities(cardId: String): DataSourceResult<List<CardActivity>> {
return repository.listActivities(cardId).toDataSourceResult()
}
override suspend fun addComment(cardId: String, comment: String): DataSourceResult<List<CardActivity>> {
val result = repository.addComment(cardId, comment)
return when (result) {
is CardDetailRepository.Result.Success -> DataSourceResult.Success(result.value)
is CardDetailRepository.Result.Failure.SessionExpired -> DataSourceResult.SessionExpired
is CardDetailRepository.Result.Failure.Generic -> {
DataSourceResult.GenericError(result.message)
}
}
}
}
class CardDetailViewModel(
private val cardId: String,
private val repository: CardDetailDataSource,
private val descriptionDebounceMillis: Long = 800L,
private val titleRequiredMessage: String = "Card title is required",
private val commentRequiredMessage: String = "Comment is required",
private val commentAddedMessage: String = "Comment added",
) : ViewModel() {
private val _uiState = MutableStateFlow(CardDetailUiState())
val uiState: StateFlow<CardDetailUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<CardDetailUiEvent>()
val events: SharedFlow<CardDetailUiEvent> = _events.asSharedFlow()
private var persistedTitle: String = ""
private var persistedDescription: String = ""
private var persistedDueDate: LocalDate? = null
private var lastAttemptedTitle: String? = null
private var lastAttemptedDescription: String? = null
private var hasLastAttemptedDueDate: Boolean = false
private var lastAttemptedDueDate: LocalDate? = null
private var lastAttemptedComment: String? = null
private var inFlightDescriptionPayload: String? = null
private var pendingDescriptionPayload: String? = null
private var descriptionDebounceJob: Job? = null
fun load() {
if (_uiState.value.isSessionExpired) {
return
}
viewModelScope.launch {
_uiState.update {
it.copy(
isInitialLoading = true,
loadErrorMessage = null,
)
}
when (val result = repository.loadCard(cardId)) {
is DataSourceResult.Success -> {
val detail = result.value
persistedTitle = detail.title
persistedDescription = detail.description
persistedDueDate = detail.dueDate
_uiState.update {
it.copy(
isInitialLoading = false,
loadErrorMessage = null,
title = detail.title,
description = detail.description,
dueDate = detail.dueDate,
tags = detail.tags,
titleErrorMessage = null,
descriptionErrorMessage = null,
dueDateErrorMessage = null,
)
}
loadActivities()
}
is DataSourceResult.SessionExpired -> {
_uiState.update {
it.copy(
isInitialLoading = false,
isSessionExpired = true,
)
}
_events.emit(CardDetailUiEvent.SessionExpired)
}
is DataSourceResult.GenericError -> {
_uiState.update {
it.copy(
isInitialLoading = false,
loadErrorMessage = result.message,
)
}
}
}
}
}
fun retryLoad() {
load()
}
fun onTitleChanged(value: String) {
_uiState.update { it.copy(title = value, titleErrorMessage = null) }
}
fun onTitleFocusLost() {
saveTitle(_uiState.value.title)
}
fun retryTitleSave() {
val payload = lastAttemptedTitle ?: return
saveTitle(payload, fromRetry = true)
}
fun onDescriptionChanged(value: String) {
if (_uiState.value.isSessionExpired) {
return
}
_uiState.update {
it.copy(
description = value,
descriptionErrorMessage = null,
isDescriptionDirty = value != persistedDescription,
)
}
scheduleDescriptionSave()
}
fun onDescriptionFocusLost() {
flushDescriptionImmediately()
}
fun onStop() {
flushDescriptionImmediately()
}
fun retryDescriptionSave() {
val payload = lastAttemptedDescription ?: return
saveDescription(payload, fromRetry = true)
}
fun setDescriptionMode(mode: DescriptionMode) {
_uiState.update { it.copy(descriptionMode = mode) }
}
fun setDueDate(dueDate: LocalDate) {
saveDueDate(dueDate)
}
fun clearDueDate() {
saveDueDate(null)
}
fun retryDueDateSave() {
if (!hasLastAttemptedDueDate) {
return
}
saveDueDate(lastAttemptedDueDate, fromRetry = true)
}
fun retryActivities() {
loadActivities()
}
fun openCommentDialog() {
_uiState.update {
it.copy(
isCommentDialogOpen = true,
commentDialogMode = CommentDialogMode.EDIT,
commentErrorMessage = null,
)
}
}
fun closeCommentDialog() {
_uiState.update {
it.copy(
isCommentDialogOpen = false,
commentDraft = "",
commentDialogMode = CommentDialogMode.EDIT,
commentErrorMessage = null,
isCommentSubmitting = false,
)
}
}
fun onCommentChanged(value: String) {
_uiState.update { it.copy(commentDraft = value, commentErrorMessage = null) }
}
fun setCommentDialogMode(mode: CommentDialogMode) {
_uiState.update { it.copy(commentDialogMode = mode) }
}
fun submitComment() {
if (_uiState.value.isSessionExpired || _uiState.value.isCommentSubmitting) {
return
}
val payload = _uiState.value.commentDraft.trim()
if (payload.isBlank()) {
_uiState.update { it.copy(commentErrorMessage = commentRequiredMessage) }
return
}
lastAttemptedComment = payload
viewModelScope.launch {
_uiState.update { it.copy(isCommentSubmitting = true, commentErrorMessage = null) }
when (val result = repository.addComment(cardId, payload)) {
is DataSourceResult.Success -> {
_uiState.update {
it.copy(
isCommentSubmitting = false,
isCommentDialogOpen = false,
commentDraft = "",
commentDialogMode = CommentDialogMode.EDIT,
activities = result.value,
activitiesErrorMessage = null,
isActivitiesLoading = false,
)
}
_events.emit(CardDetailUiEvent.ShowSnackbar(commentAddedMessage))
}
is DataSourceResult.SessionExpired -> {
_uiState.update {
it.copy(
isCommentSubmitting = false,
isSessionExpired = true,
)
}
_events.emit(CardDetailUiEvent.SessionExpired)
}
is DataSourceResult.GenericError -> {
_uiState.update {
it.copy(
isCommentSubmitting = false,
commentErrorMessage = result.message,
)
}
}
}
}
}
fun retryAddComment() {
if (_uiState.value.isSessionExpired) {
return
}
val payload = lastAttemptedComment ?: return
_uiState.update { it.copy(commentDraft = payload) }
submitComment()
}
private fun saveTitle(rawTitle: String, fromRetry: Boolean = false) {
if (_uiState.value.isSessionExpired || _uiState.value.isTitleSaving) {
return
}
val normalized = rawTitle.trim()
if (normalized.isBlank()) {
_uiState.update { it.copy(titleErrorMessage = titleRequiredMessage) }
return
}
if (!fromRetry && normalized == persistedTitle) {
return
}
lastAttemptedTitle = normalized
viewModelScope.launch {
_uiState.update { it.copy(isTitleSaving = true, titleErrorMessage = null) }
when (val result = repository.updateTitle(cardId, normalized)) {
is DataSourceResult.Success -> {
persistedTitle = normalized
_uiState.update {
it.copy(
title = normalized,
isTitleSaving = false,
titleErrorMessage = null,
)
}
}
is DataSourceResult.SessionExpired -> {
_uiState.update {
it.copy(
isTitleSaving = false,
isSessionExpired = true,
)
}
_events.emit(CardDetailUiEvent.SessionExpired)
}
is DataSourceResult.GenericError -> {
_uiState.update {
it.copy(
isTitleSaving = false,
titleErrorMessage = result.message,
)
}
}
}
}
}
private fun scheduleDescriptionSave() {
if (_uiState.value.isSessionExpired) {
return
}
descriptionDebounceJob?.cancel()
descriptionDebounceJob = viewModelScope.launch {
delay(descriptionDebounceMillis)
saveDescription(_uiState.value.description)
}
}
private fun flushDescriptionImmediately() {
if (_uiState.value.isSessionExpired || !_uiState.value.isDescriptionDirty) {
return
}
descriptionDebounceJob?.cancel()
saveDescription(_uiState.value.description)
}
private fun saveDescription(rawDescription: String, fromRetry: Boolean = false) {
if (_uiState.value.isSessionExpired) {
return
}
val normalized = rawDescription
if (!fromRetry && normalized == persistedDescription) {
_uiState.update { it.copy(isDescriptionDirty = it.description != persistedDescription) }
return
}
if (!fromRetry && normalized == inFlightDescriptionPayload) {
return
}
if (_uiState.value.isDescriptionSaving) {
if (normalized != inFlightDescriptionPayload) {
pendingDescriptionPayload = normalized
}
return
}
lastAttemptedDescription = normalized
pendingDescriptionPayload = null
viewModelScope.launch {
inFlightDescriptionPayload = normalized
_uiState.update { it.copy(isDescriptionSaving = true, descriptionErrorMessage = null) }
when (val result = repository.updateDescription(cardId, normalized)) {
is DataSourceResult.Success -> {
persistedDescription = normalized
_uiState.update {
val stillDirty = it.description != persistedDescription
it.copy(
isDescriptionSaving = false,
isDescriptionDirty = stillDirty,
descriptionErrorMessage = null,
)
}
}
is DataSourceResult.SessionExpired -> {
_uiState.update {
it.copy(
isDescriptionSaving = false,
isSessionExpired = true,
)
}
_events.emit(CardDetailUiEvent.SessionExpired)
}
is DataSourceResult.GenericError -> {
_uiState.update {
it.copy(
isDescriptionSaving = false,
isDescriptionDirty = it.description != persistedDescription,
descriptionErrorMessage = result.message,
)
}
}
}
inFlightDescriptionPayload = null
val pending = pendingDescriptionPayload
if (!_uiState.value.isSessionExpired && pending != null && pending != persistedDescription) {
saveDescription(pending)
}
}
}
private fun saveDueDate(dueDate: LocalDate?, fromRetry: Boolean = false) {
if (_uiState.value.isSessionExpired || _uiState.value.isDueDateSaving) {
return
}
if (!fromRetry && dueDate == persistedDueDate) {
_uiState.update { it.copy(dueDate = dueDate, dueDateErrorMessage = null) }
return
}
hasLastAttemptedDueDate = true
lastAttemptedDueDate = dueDate
viewModelScope.launch {
_uiState.update {
it.copy(
dueDate = dueDate,
isDueDateSaving = true,
dueDateErrorMessage = null,
)
}
when (val result = repository.updateDueDate(cardId, dueDate)) {
is DataSourceResult.Success -> {
persistedDueDate = dueDate
_uiState.update {
it.copy(
isDueDateSaving = false,
dueDateErrorMessage = null,
)
}
}
is DataSourceResult.SessionExpired -> {
_uiState.update {
it.copy(
isDueDateSaving = false,
isSessionExpired = true,
)
}
_events.emit(CardDetailUiEvent.SessionExpired)
}
is DataSourceResult.GenericError -> {
_uiState.update {
it.copy(
isDueDateSaving = false,
dueDateErrorMessage = result.message,
)
}
}
}
}
}
private fun loadActivities() {
if (_uiState.value.isSessionExpired) {
return
}
viewModelScope.launch {
_uiState.update { it.copy(isActivitiesLoading = true, activitiesErrorMessage = null) }
when (val result = repository.listActivities(cardId)) {
is DataSourceResult.Success -> {
_uiState.update {
it.copy(
isActivitiesLoading = false,
activities = result.value,
activitiesErrorMessage = null,
)
}
}
is DataSourceResult.SessionExpired -> {
_uiState.update {
it.copy(
isActivitiesLoading = false,
isSessionExpired = true,
)
}
_events.emit(CardDetailUiEvent.SessionExpired)
}
is DataSourceResult.GenericError -> {
_uiState.update {
it.copy(
isActivitiesLoading = false,
activitiesErrorMessage = result.message,
)
}
}
}
}
}
class Factory(
private val cardId: String,
private val dataSource: CardDetailDataSource,
private val titleRequiredMessage: String = "Card title is required",
private val commentRequiredMessage: String = "Comment is required",
private val commentAddedMessage: String = "Comment added",
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(CardDetailViewModel::class.java)) {
return CardDetailViewModel(
cardId = cardId,
repository = dataSource,
titleRequiredMessage = titleRequiredMessage,
commentRequiredMessage = commentRequiredMessage,
commentAddedMessage = commentAddedMessage,
) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}
}
private fun <T> CardDetailRepository.Result<T>.toDataSourceResult(): DataSourceResult<T> {
return when (this) {
is CardDetailRepository.Result.Success -> DataSourceResult.Success(value)
is CardDetailRepository.Result.Failure.Generic -> DataSourceResult.GenericError(message)
is CardDetailRepository.Result.Failure.SessionExpired -> DataSourceResult.SessionExpired
}
}

View File

@@ -0,0 +1,81 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import android.text.Spanned
import android.text.method.MovementMethod
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.core.text.HtmlCompat
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
class MarkdownRenderer(
private val parseToHtml: (String) -> String = defaultParseToHtml(),
private val htmlToSpanned: (String) -> Spanned = { html ->
HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY)
},
) {
fun render(markdown: String): Spanned {
return try {
val html = parseToHtml(markdown)
htmlToSpanned(html)
} catch (_: Exception) {
PlainTextSpanned(markdown)
}
}
companion object {
fun enableLinks(textView: TextView) {
enableLinks(
applyMovementMethod = { movementMethod -> textView.movementMethod = movementMethod },
movementMethodProvider = { LinkMovementMethod.getInstance() },
)
}
fun enableLinks(
applyMovementMethod: (MovementMethod) -> Unit,
movementMethodProvider: () -> MovementMethod = { LinkMovementMethod.getInstance() },
) {
applyMovementMethod(movementMethodProvider())
}
private fun defaultParseToHtml(): (String) -> String {
val parser = Parser.builder().build()
val renderer = HtmlRenderer.builder()
.escapeHtml(true)
.build()
return { markdown ->
val document = parser.parse(markdown)
renderer.render(document)
}
}
}
}
private class PlainTextSpanned(
private val text: String,
) : Spanned {
override val length: Int
get() = text.length
override fun get(index: Int): Char = text[index]
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence {
return text.subSequence(startIndex, endIndex)
}
override fun toString(): String = text
override fun getSpanStart(tag: Any): Int = -1
override fun getSpanEnd(tag: Any): Int = -1
override fun getSpanFlags(tag: Any): Int = 0
override fun nextSpanTransition(start: Int, limit: Int, kind: Class<*>?): Int = limit
override fun <T : Any> getSpans(start: Int, end: Int, kind: Class<T>): Array<T> {
@Suppress("UNCHECKED_CAST")
return java.lang.reflect.Array.newInstance(kind, 0) as Array<T>
}
}

View File

@@ -0,0 +1,222 @@
package space.hackenslacker.kanbn4droid.app.settings
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.commitNow
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.BoardsActivity
import space.hackenslacker.kanbn4droid.app.MainActivity
import space.hackenslacker.kanbn4droid.app.R
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.PreferencesApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyCoordinator
import space.hackenslacker.kanbn4droid.app.auth.SettingsApplyResult
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
class SettingsDialogFragment : DialogFragment() {
lateinit var sessionStore: SessionStore
private set
private lateinit var apiClient: KanbnApiClient
private lateinit var apiKeyStore: ApiKeyStore
private var progress: ProgressBar? = null
private var saveButton: Button? = null
private var errorText: TextView? = null
private var controlsEnabled: Boolean = true
override fun onAttach(context: Context) {
super.onAttach(context)
resolveDependencies(context)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialogView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_settings, null)
childFragmentManager.commitNow {
replace(
R.id.settingsFragmentContainer,
SettingsPreferencesFragment(),
SETTINGS_PREFS_TAG,
)
}
progress = dialogView.findViewById(R.id.settingsApplyProgress)
errorText = dialogView.findViewById(R.id.settingsErrorText)
saveButton = dialogView.findViewById(R.id.settingsSaveAndCloseButton)
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.drawer_settings)
.setView(dialogView)
.setCancelable(false)
.create()
isCancelable = false
dialog.setCanceledOnTouchOutside(false)
dialog.setOnShowListener {
saveButton?.setOnClickListener {
onSaveClicked()
}
setApplyInProgress(false)
}
return dialog
}
private fun onSaveClicked() {
if (!controlsEnabled) {
return
}
val hostActivity = requireActivity() as AppCompatActivity
val resolvedCoordinator = MainActivity.dependencies.settingsApplyCoordinatorFactory?.invoke(
hostActivity,
sessionStore,
apiClient,
apiKeyStore,
)
?: SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
lifecycleScope.launch {
setApplyInProgress(true)
val previousTheme = sessionStore.getThemeMode()
when (val result = resolvedCoordinator.apply()) {
is SettingsApplyResult.SuccessCredentialChange -> {
val themeChanged = sessionStore.getThemeMode() != previousTheme
if (themeChanged) {
applyThemeFrom(sessionStore.getThemeMode())
}
parentFragmentManager.setFragmentResult(
REQUEST_KEY_SETTINGS_APPLIED,
bundleOf(
RESULT_KEY_CREDENTIALS_CHANGED to true,
RESULT_KEY_THEME_CHANGED to themeChanged,
),
)
dismissAllowingStateLoss()
}
is SettingsApplyResult.SuccessNoCredentialChange -> {
if (result.themeChanged) {
applyThemeFrom(sessionStore.getThemeMode())
}
parentFragmentManager.setFragmentResult(
REQUEST_KEY_SETTINGS_APPLIED,
bundleOf(
RESULT_KEY_CREDENTIALS_CHANGED to false,
RESULT_KEY_THEME_CHANGED to result.themeChanged,
),
)
dismissAllowingStateLoss()
}
SettingsApplyResult.NoChanges -> {
parentFragmentManager.setFragmentResult(
REQUEST_KEY_SETTINGS_APPLIED,
bundleOf(
RESULT_KEY_CREDENTIALS_CHANGED to false,
RESULT_KEY_THEME_CHANGED to false,
),
)
dismissAllowingStateLoss()
}
is SettingsApplyResult.ValidationError -> {
setApplyInProgress(false)
showError(result.message)
findPreferencesFragment()?.focusField(result.field)
}
is SettingsApplyResult.AuthError -> {
setApplyInProgress(false)
showError(result.message)
}
is SettingsApplyResult.NetworkError -> {
setApplyInProgress(false)
showError(result.message)
}
}
}
}
private fun setApplyInProgress(inProgress: Boolean) {
controlsEnabled = !inProgress
progress?.visibility = if (inProgress) android.view.View.VISIBLE else android.view.View.GONE
saveButton?.isEnabled = !inProgress
findPreferencesFragment()?.preferenceScreen?.isEnabled = !inProgress
if (inProgress) {
errorText?.visibility = android.view.View.GONE
}
}
private fun showError(message: String) {
errorText?.text = message
errorText?.visibility = android.view.View.VISIBLE
}
private fun findPreferencesFragment(): SettingsPreferencesFragment? {
return childFragmentManager.findFragmentByTag(SETTINGS_PREFS_TAG) as? SettingsPreferencesFragment
}
private fun applyThemeFrom(themeMode: String) {
val mode = when (themeMode.lowercase()) {
"light" -> AppCompatDelegate.MODE_NIGHT_NO
"dark" -> AppCompatDelegate.MODE_NIGHT_YES
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
AppCompatDelegate.setDefaultNightMode(mode)
}
private fun resolveDependencies(context: Context) {
val host = activity as? BoardsActivity
if (host != null) {
sessionStore = host.sessionStoreForSettingsDialog
apiClient = host.apiClientForSettingsDialog
apiKeyStore = host.apiKeyStoreForSettingsDialog
return
}
val appCompatActivity = activity as? AppCompatActivity
if (appCompatActivity != null) {
sessionStore = MainActivity.dependencies.sessionStoreFactory?.invoke(appCompatActivity)
?: SessionPreferences(appCompatActivity.applicationContext)
apiClient = MainActivity.dependencies.apiClientFactory?.invoke()
?: HttpKanbnApiClient()
apiKeyStore = MainActivity.dependencies.apiKeyStoreFactory?.invoke(appCompatActivity)
?: PreferencesApiKeyStore(appCompatActivity)
return
}
sessionStore = SessionPreferences(context.applicationContext)
apiClient = HttpKanbnApiClient()
apiKeyStore = PreferencesApiKeyStore(context)
}
companion object {
const val TAG: String = "settings_dialog"
const val REQUEST_KEY_SETTINGS_APPLIED: String = "settings_applied_result"
const val RESULT_KEY_CREDENTIALS_CHANGED: String = "credentials_changed"
const val RESULT_KEY_THEME_CHANGED: String = "theme_changed"
private const val SETTINGS_PREFS_TAG: String = "settings_preferences_fragment"
fun newInstance(): SettingsDialogFragment {
return SettingsDialogFragment()
}
}
}

View File

@@ -0,0 +1,75 @@
package space.hackenslacker.kanbn4droid.app.settings
import android.os.Bundle
import android.text.InputType
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import space.hackenslacker.kanbn4droid.app.R
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
class SettingsPreferencesFragment : PreferenceFragmentCompat() {
private lateinit var sessionStore: SessionStore
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
sessionStore = requireNotNull((requireParentFragment() as SettingsDialogFragment).sessionStore)
setPreferencesFromResource(R.xml.settings_preferences, rootKey)
bindThemePreference()
bindBaseUrlPreference()
bindApiKeyPreference()
}
fun focusField(field: String) {
when (field) {
FIELD_BASE_URL -> findPreference<Preference>(KEY_BASE_URL)?.performClick()
FIELD_API_KEY -> findPreference<Preference>(KEY_API_KEY)?.performClick()
}
}
private fun bindThemePreference() {
val pref = findPreference<ListPreference>(KEY_THEME) ?: return
pref.value = sessionStore.getDraftThemeMode().ifBlank { "system" }
pref.setOnPreferenceChangeListener { _, newValue ->
sessionStore.saveDraftThemeMode(newValue?.toString().orEmpty())
true
}
}
private fun bindBaseUrlPreference() {
val pref = findPreference<EditTextPreference>(KEY_BASE_URL) ?: return
pref.text = sessionStore.getDraftBaseUrl().orEmpty()
pref.setOnPreferenceChangeListener { _, newValue ->
sessionStore.saveDraftBaseUrl(newValue?.toString().orEmpty())
true
}
}
private fun bindApiKeyPreference() {
val pref = findPreference<EditTextPreference>(KEY_API_KEY) ?: return
pref.setOnBindEditTextListener { editText ->
editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
pref.text = sessionStore.getDraftApiKey().orEmpty()
pref.summaryProvider = Preference.SummaryProvider<EditTextPreference> { preference ->
if (preference.text.isNullOrBlank()) {
getString(R.string.settings_api_key_summary_not_configured)
} else {
getString(R.string.settings_api_key_summary_configured)
}
}
pref.setOnPreferenceChangeListener { _, newValue ->
sessionStore.saveDraftApiKey(newValue?.toString().orEmpty())
true
}
}
companion object {
const val KEY_THEME: String = "pref_theme_draft"
const val KEY_BASE_URL: String = "pref_base_url_draft"
const val KEY_API_KEY: String = "pref_api_key_draft"
const val FIELD_BASE_URL: String = "baseUrl"
const val FIELD_API_KEY: String = "apiKey"
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.14" android:color="?attr/colorPrimary" android:state_activated="true" />
<item android:alpha="0.14" android:color="?attr/colorPrimary" android:state_selected="true" />
<item android:color="?attr/colorSurface" />
</selector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.70" android:color="?attr/colorPrimary" android:state_activated="true" />
<item android:alpha="0.70" android:color="?attr/colorPrimary" android:state_selected="true" />
<item android:alpha="0.20" android:color="?attr/colorOnSurface" />
</selector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?attr/colorPrimary" android:state_activated="true" />
<item android:color="?attr/colorPrimary" android:state_selected="true" />
<item android:color="?attr/colorOnSurface" />
</selector>

View 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="#FFFFFFFF"
android:pathData="M9,3h6l1,1h4v2h-16v-2h4zM6,8h12l-1,12h-10zM9,10v8h2v-8zM13,10v8h2v-8z" />
</vector>

View 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="#FFFFFFFF"
android:pathData="M4,7h16v2H4zM7,11h10v2H7zM10,15h4v2h-4z" />
</vector>

View 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="#FFFFFFFF"
android:pathData="M7.5,6l-4.5,6l4.5,6v-4h9v4l4.5,-6l-4.5,-6v4h-9z" />
</vector>

View 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="#FFFFFFFF"
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>

View 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="#FFFFFFFF"
android:pathData="M3,3h4v4h-4zM8.5,3h4v4h-4zM14,3h4v4h-4zM19.5,3h1.5v4h-1.5zM3,8.5h4v4h-4zM8.5,8.5h4v4h-4zM14,8.5h4v4h-4zM19.5,8.5h1.5v4h-1.5zM3,14h4v4h-4zM8.5,14h4v4h-4zM14,14h4v4h-4zM19.5,14h1.5v4h-1.5zM3,19.5h4v1.5h-4zM8.5,19.5h4v1.5h-4zM14,19.5h4v1.5h-4zM19.5,19.5h1.5v1.5h-1.5z" />
</vector>

View 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="M9,3h6l1,1h4v2h-16v-2h4zM6,8h12l-1,12h-10zM9,10v8h2v-8zM13,10v8h2v-8z" />
</vector>

View 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>

View 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="M7.5,6l-4.5,6l4.5,6v-4h9v4l4.5,-6l-4.5,-6v4h-9z" />
</vector>

View 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>

View 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="M3,3h4v4h-4zM8.5,3h4v4h-4zM14,3h4v4h-4zM19.5,3h1.5v4h-1.5zM3,8.5h4v4h-4zM8.5,8.5h4v4h-4zM14,8.5h4v4h-4zM19.5,8.5h1.5v4h-1.5zM3,14h4v4h-4zM8.5,14h4v4h-4zM14,14h4v4h-4zM19.5,14h1.5v4h-1.5zM3,19.5h4v1.5h-4zM8.5,19.5h4v1.5h-4zM14,19.5h4v1.5h-4zM19.5,19.5h1.5v1.5h-1.5z" />
</vector>

View File

@@ -0,0 +1,19 @@
<?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,3h11l5,5v13H4z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M15,3v5h5" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M7,11h10v1.6H7zM7,14.2h10v1.6H7zM7,17.4h7v1.6H7z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M7.8,9.4l1.2,1.2 3.2,-3.2 -1.2,-1.2z" />
</vector>

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/boardDetailToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/boardDetailPager"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<TextView
android:id="@+id/boardDetailEmptyBoardText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:padding="24dp"
android:text="@string/board_detail_empty_board"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:visibility="gone" />
<ProgressBar
android:id="@+id/boardDetailInitialProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<LinearLayout
android:id="@+id/boardDetailFullScreenErrorContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp"
android:visibility="gone">
<TextView
android:id="@+id/boardDetailFullScreenErrorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<com.google.android.material.button.MaterialButton
android:id="@+id/boardDetailRetryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/retry" />
</LinearLayout>
</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>

View File

@@ -1,6 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/boardsDrawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
@@ -19,7 +24,8 @@
<FrameLayout <FrameLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
>
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/boardsRecyclerView" android:id="@+id/boardsRecyclerView"
@@ -56,5 +62,12 @@
android:layout_margin="16dp" android:layout_margin="16dp"
android:contentDescription="@string/create_board" android:contentDescription="@string/create_board"
app:srcCompat="@android:drawable/ic_input_add" /> app:srcCompat="@android:drawable/ic_input_add" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout> <include
layout="@layout/view_boards_drawer"
android:layout_width="@dimen/boards_drawer_max_width"
android:layout_height="match_parent"
android:layout_gravity="start" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@@ -0,0 +1,300 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/cardDetailToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<ProgressBar
android:id="@+id/cardDetailInitialProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<LinearLayout
android:id="@+id/cardDetailErrorContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp"
android:visibility="gone">
<TextView
android:id="@+id/cardDetailErrorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailRetryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/retry" />
</LinearLayout>
<androidx.core.widget.NestedScrollView
android:id="@+id/cardDetailContentScroll"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:visibility="gone">
<LinearLayout
android:id="@+id/cardDetailContentContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/cardDetailTitleInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/card_detail_title">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/cardDetailTitleInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textCapSentences|text"
android:maxLines="1"
android:textStyle="bold" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/cardDetailTitleSavingText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/card_detail_saving"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:visibility="gone" />
<TextView
android:id="@+id/cardDetailTitleErrorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorError"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailTitleRetryButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/cardDetailTagsRecycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:nestedScrollingEnabled="false" />
<LinearLayout
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/cardDetailDueDateText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/card_detail_set_due_date"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailDueDateClearButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/clear_due_date"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/cardDetailDueDateSavingText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/card_detail_saving"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:visibility="gone" />
<TextView
android:id="@+id/cardDetailDueDateErrorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorError"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailDueDateRetryButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry"
android:visibility="gone" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/cardDetailDescriptionModeToggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:singleSelection="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailDescriptionEditButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/card_detail_edit" />
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailDescriptionPreviewButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/card_detail_preview" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/cardDetailDescriptionInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/description">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/cardDetailDescriptionInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top|start"
android:inputType="textMultiLine|textCapSentences"
android:minLines="6" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/cardDetailDescriptionPreviewText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:visibility="gone" />
<TextView
android:id="@+id/cardDetailDescriptionSavingText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/card_detail_saving"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:visibility="gone" />
<TextView
android:id="@+id/cardDetailDescriptionErrorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorError"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailDescriptionRetryButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry"
android:visibility="gone" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/card_detail_timeline"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" />
<ProgressBar
android:id="@+id/cardDetailTimelineProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:visibility="gone" />
<TextView
android:id="@+id/cardDetailTimelineErrorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorError"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/cardDetailTimelineRetryButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry"
android:visibility="gone" />
<TextView
android:id="@+id/cardDetailTimelineEmptyText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/card_detail_timeline_empty"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/cardDetailTimelineRecycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:nestedScrollingEnabled="false" />
<View
android:layout_width="match_parent"
android:layout_height="72dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</FrameLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/cardDetailAddCommentFab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="@string/card_detail_add_comment"
app:srcCompat="@android:drawable/ic_input_add" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View 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>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="8dp"
android:paddingEnd="24dp"
android:paddingBottom="8dp">
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/commentDialogModeToggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:singleSelection="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/commentDialogEditButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/card_detail_edit" />
<com.google.android.material.button.MaterialButton
android:id="@+id/commentDialogPreviewButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/card_detail_preview" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/commentDialogInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/card_detail_comment_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/commentDialogInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top|start"
android:inputType="textMultiLine|textCapSentences"
android:minLines="4" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/commentDialogPreviewText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:visibility="gone" />
<TextView
android:id="@+id/commentDialogErrorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorError"
android:visibility="gone" />
</LinearLayout>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,49 @@
<?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">
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fillViewport="true">
<FrameLayout
android:id="@+id/settingsFragmentContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="320dp" />
</ScrollView>
<ProgressBar
android:id="@+id/settingsApplyProgress"
style="@style/Widget.AppCompat.ProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="12dp"
android:visibility="gone" />
<TextView
android:id="@+id/settingsErrorText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/settingsSaveAndCloseButton"
style="@style/Widget.Material3.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="12dp"
android:text="@string/settings_save_and_close" />
</LinearLayout>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/cardItemRoot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardCornerRadius="16dp"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:id="@+id/cardTitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/cardTagsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal" />
<TextView
android:id="@+id/cardDueDateText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingTop="12dp"
android:paddingBottom="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/listTitleInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:hintEnabled="false">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/listTitleEditInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/list_title_hint"
android:imeOptions="actionDone"
android:inputType="textCapSentences|text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/listTitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:paddingVertical="8dp"
android:textAppearance="@style/TextAppearance.Material3.TitleLarge"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/listCardsRecycler"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:paddingBottom="8dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
<TextView
android:id="@+id/listEmptyText"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:padding="12dp"
android:text="@string/board_detail_empty_list"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,57 @@
<?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="horizontal"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<LinearLayout
android:layout_width="36dp"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<View
android:id="@+id/timelineTopConnector"
android:layout_width="2dp"
android:layout_height="12dp"
android:background="@android:color/darker_gray" />
<ImageView
android:id="@+id/timelineIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_timeline_note_24"
android:tint="@android:color/darker_gray" />
<View
android:id="@+id/timelineBottomConnector"
android:layout_width="2dp"
android:layout_height="match_parent"
android:background="@android:color/darker_gray" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/timelineHeaderText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textStyle="bold" />
<TextView
android:id="@+id/timelineBodyText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
app:cardBackgroundColor="@color/workspace_drawer_row_background"
app:cardCornerRadius="12dp"
app:strokeColor="@color/workspace_drawer_row_stroke"
app:strokeWidth="1dp">
<TextView
android:id="@+id/workspaceTitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:duplicateParentState="true"
android:paddingStart="16dp"
android:paddingTop="12dp"
android:paddingEnd="16dp"
android:paddingBottom="12dp"
android:textColor="@color/workspace_drawer_row_text"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1" />
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/boardsDrawerContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="24dp"
android:paddingEnd="16dp"
android:paddingBottom="16dp">
<TextView
android:id="@+id/drawerUsernameText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/drawer_profile_unavailable"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textStyle="bold" />
<TextView
android:id="@+id/drawerEmailText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/drawer_profile_unavailable"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2" />
<Button
android:id="@+id/drawerSettingsButton"
style="?attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/drawer_settings" />
<TextView
android:id="@+id/drawerWorkspacesTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/drawer_workspaces"
android:textAppearance="@style/TextAppearance.MaterialComponents.Overline" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/drawerWorkspacesRecyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:clipToPadding="false"
android:paddingBottom="8dp" />
<ProgressBar
android:id="@+id/drawerLoadingIndicator"
style="@style/Widget.AppCompat.ProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="8dp"
android:visibility="gone" />
<TextView
android:id="@+id/drawerErrorText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/drawer_workspaces_unavailable"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:visibility="gone" />
<Button
android:id="@+id/drawerRetryButton"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/drawer_retry"
android:visibility="gone" />
<Button
android:id="@+id/drawerLogoutButton"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/drawer_logout" />
</LinearLayout>

View 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>

View File

@@ -0,0 +1,22 @@
<?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/actionSelectAll"
android:icon="@drawable/ic_select_all_grid_24"
android:title="@string/select_all"
app:showAsAction="always" />
<item
android:id="@+id/actionMoveCards"
android:icon="@drawable/ic_move_cards_horizontal_24"
android:title="@string/move_cards"
app:showAsAction="always" />
<item
android:id="@+id/actionDeleteCards"
android:icon="@drawable/ic_delete_24"
android:title="@string/delete_cards"
app:showAsAction="always" />
</menu>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="boards_drawer_max_width">360dp</dimen>
</resources>

View File

@@ -32,4 +32,82 @@
<string name="network_unreachable">Cannot reach server. Check your connection and URL.</string> <string name="network_unreachable">Cannot reach server. Check your connection and URL.</string>
<string name="auth_failed">Authentication failed. Check your API key.</string> <string name="auth_failed">Authentication failed. Check your API key.</string>
<string name="unexpected_error">Unexpected error. Please try again.</string> <string name="unexpected_error">Unexpected error. Please try again.</string>
<string name="board_detail_empty_board">No lists yet.</string>
<string name="board_detail_empty_list">No cards in this list.</string>
<string name="retry">Retry</string>
<string name="list_title_hint">List title</string>
<string name="list_title_required">List title is required</string>
<string name="select_all">Select all</string>
<string name="move_cards">Move cards</string>
<string name="delete_cards">Delete cards</string>
<string name="delete_cards_title">Delete selected cards</string>
<string name="move_cards_to_list">Move cards to list</string>
<string name="delete_cards_confirmation">Delete selected cards?</string>
<string name="delete_cards_second_confirmation">Are you sure you want to permanently delete the selected cards?</string>
<string name="card_detail_fallback_title">Card</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_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>
<string name="card_detail_unable_to_open_card">Unable to open card.</string>
<string name="card_detail_session_expired">Session expired. Please sign in again.</string>
<string name="card_detail_title">Title</string>
<string name="card_detail_title_default">Card</string>
<string name="card_detail_set_due_date">Set due date</string>
<string name="card_detail_saving">Saving...</string>
<string name="card_detail_edit">Edit</string>
<string name="card_detail_preview">Preview</string>
<string name="card_detail_timeline">Timeline</string>
<string name="card_detail_timeline_empty">No activity yet.</string>
<string name="card_detail_add_comment">Add comment</string>
<string name="card_detail_comment_hint">Comment</string>
<string name="card_detail_timeline_header">%1$s %2$s - %3$s</string>
<string name="card_detail_timeline_actor_unknown">Someone</string>
<string name="card_detail_timeline_action_commented">commented</string>
<string name="card_detail_timeline_action_updated">updated this card</string>
<string name="add">Add</string>
<string name="card_detail_comment_required">Comment is required</string>
<string name="card_detail_comment_added">Comment added</string>
<string name="drawer_settings">Settings</string>
<string name="drawer_workspaces">Workspaces</string>
<string name="drawer_logout">Log out</string>
<string name="drawer_logout_confirmation_message">Log out and clear this device session?</string>
<string name="drawer_retry">Retry</string>
<string name="drawer_profile_unavailable">Profile unavailable</string>
<string name="drawer_workspaces_unavailable">Workspaces unavailable</string>
<string name="drawer_loading">Loading...</string>
<string name="settings_theme_title">Theme</string>
<string name="settings_base_url_title">Base URL</string>
<string name="settings_api_key_title">API key</string>
<string name="settings_api_key_summary_not_configured">Not configured</string>
<string name="settings_api_key_summary_configured">Configured</string>
<string name="settings_save_and_close">Save and close</string>
<string-array name="settings_theme_entries">
<item>Light</item>
<item>Dark</item>
<item>Follow System</item>
</string-array>
<string-array name="settings_theme_values">
<item>light</item>
<item>dark</item>
<item>system</item>
</string-array>
</resources> </resources>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ListPreference
android:defaultValue="system"
android:entries="@array/settings_theme_entries"
android:entryValues="@array/settings_theme_values"
android:key="pref_theme_draft"
android:title="@string/settings_theme_title" />
<EditTextPreference
android:defaultValue="@string/default_base_url"
android:key="pref_base_url_draft"
android:title="@string/settings_base_url_title"
app:useSimpleSummaryProvider="true" />
<EditTextPreference
android:key="pref_api_key_draft"
android:inputType="textPassword"
android:summary="@string/settings_api_key_summary_not_configured"
android:title="@string/settings_api_key_title"
app:useSimpleSummaryProvider="false" />
</PreferenceScreen>

View File

@@ -0,0 +1,38 @@
package space.hackenslacker.kanbn4droid.app.auth
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class ApiKeyStoreKeyDerivationTest {
@Test
fun equivalentNormalizedBaseUrlsProduceSamePreferenceKey() {
val first = deriveApiKeyPreferenceKey(" HTTPS://KAN.BN/api/v1 ")
val second = deriveApiKeyPreferenceKey("https://kan.bn/api/v1/")
val third = deriveApiKeyPreferenceKey("https://kan.bn/api/v1")
assertEquals(first, second)
assertEquals(second, third)
}
@Test
fun differentNormalizedBaseUrlsProduceDifferentPreferenceKeys() {
val first = deriveApiKeyPreferenceKey("https://kan.bn/")
val second = deriveApiKeyPreferenceKey("https://next.kan.bn/")
assertNotEquals(first, second)
}
@Test
fun malformedBaseUrlsUseTrimmedDeterministicFallback() {
val first = deriveApiKeyPreferenceKey("not a url")
val second = deriveApiKeyPreferenceKey("not a url")
val trimmedEquivalent = deriveApiKeyPreferenceKey(" not a url ")
val different = deriveApiKeyPreferenceKey("::://")
assertEquals(first, second)
assertEquals(first, trimmedEquivalent)
assertNotEquals(first, different)
}
}

View File

@@ -0,0 +1,413 @@
package space.hackenslacker.kanbn4droid.app.auth
import java.io.BufferedInputStream
import java.io.OutputStream
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
import space.hackenslacker.kanbn4droid.app.carddetail.CardActivity
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetail
class CardDetailApiCompatibilityTest {
@Test
fun getCardDetail_parsesNestedContainers_andNormalizesDueDateVariants() = runTest {
TestServer().use { server ->
server.registerSequence(
path = "/api/v1/cards/card-1",
method = "GET",
responses = listOf(
200 to """
{
"data": {
"card": {
"public_id": "card-1",
"name": "Direct date",
"body": "Desc",
"dueDate": "2026-03-16",
"listPublicId": "list-a",
"index": 4,
"labels": [
{"publicId": "tag-1", "name": "Urgent", "color": "#FF0000"}
]
}
}
}
""".trimIndent(),
200 to """
{
"card": {
"publicId": "card-1",
"title": "Instant date",
"description": "Desc",
"dueAt": "2026-03-16T23:30:00-02:00"
}
}
""".trimIndent(),
),
)
val client = HttpKanbnApiClient()
val first = client.getCardDetail(server.baseUrl, "api", "card-1")
val second = client.getCardDetail(server.baseUrl, "api", "card-1")
val firstDetail = (first as BoardsApiResult.Success<CardDetail>).value
val secondDetail = (second as BoardsApiResult.Success<CardDetail>).value
assertEquals(LocalDate.of(2026, 3, 16), firstDetail.dueDate)
assertEquals(LocalDate.of(2026, 3, 17), secondDetail.dueDate)
assertEquals("card-1", firstDetail.id)
assertEquals("Direct date", firstDetail.title)
assertEquals("Desc", firstDetail.description)
assertEquals("list-a", firstDetail.listPublicId)
assertEquals(4, firstDetail.index)
assertEquals("tag-1", firstDetail.tags.first().id)
}
}
@Test
fun updateCard_usesFallbackOrder_andFinalFullPut() = runTest {
TestServer().use { server ->
server.registerSequence(
path = "/api/v1/cards/card-2",
method = "PATCH",
responses = listOf(
400 to "{}",
409 to "{}",
),
)
server.registerSequence(
path = "/api/v1/cards/card-2",
method = "PUT",
responses = listOf(
400 to "{}",
500 to "{}",
200 to "{}",
),
)
server.register(
path = "/api/v1/cards/card-2",
method = "GET",
status = 200,
responseBody =
"""
{
"card": {
"publicId": "card-2",
"title": "Current title",
"description": "Current desc",
"index": 11,
"listPublicId": "list-old",
"dueDate": "2026-01-20T00:00:00Z"
}
}
""".trimIndent(),
)
val result = HttpKanbnApiClient().updateCard(
baseUrl = server.baseUrl,
apiKey = "api",
cardId = "card-2",
title = "New title",
description = "New desc",
dueDate = LocalDate.of(2026, 3, 18),
)
assertTrue(result is BoardsApiResult.Success<*>)
val requests = server.findRequests("/api/v1/cards/card-2")
assertEquals("PATCH", requests[0].method)
assertEquals("PATCH", requests[1].method)
assertEquals("PUT", requests[2].method)
assertEquals("PUT", requests[3].method)
assertEquals("GET", requests[4].method)
assertEquals("PUT", requests[5].method)
assertTrue(requests[0].body.contains("\"title\":\"New title\""))
assertTrue(requests[0].body.contains("\"description\":\"New desc\""))
assertTrue(requests[0].body.contains("\"dueDate\":\"2026-03-18T00:00:00Z\""))
assertTrue(requests[1].body.contains("\"name\":\"New title\""))
assertTrue(requests[1].body.contains("\"body\":\"New desc\""))
assertTrue(requests[1].body.contains("\"dueAt\":\"2026-03-18T00:00:00Z\""))
assertTrue(requests[2].body.contains("\"title\":\"New title\""))
assertTrue(requests[3].body.contains("\"name\":\"New title\""))
assertTrue(requests[5].body.contains("\"listPublicId\":\"list-old\""))
assertTrue(requests[5].body.contains("\"index\":11"))
}
}
@Test
fun updateCard_stopsFallbackOnAuthFailure() = runTest {
TestServer().use { server ->
server.register(path = "/api/v1/cards/card-auth", method = "PATCH", status = 401, responseBody = "{}")
val result = HttpKanbnApiClient().updateCard(
baseUrl = server.baseUrl,
apiKey = "api",
cardId = "card-auth",
title = "Title",
description = "Desc",
dueDate = null,
)
assertTrue(result is BoardsApiResult.Failure)
val requests = server.findRequests("/api/v1/cards/card-auth")
assertEquals(1, requests.size)
assertEquals("PATCH", requests.first().method)
}
}
@Test
fun listCardActivities_fallsBackToThirdEndpoint_whenFirstTwo2xxPayloadsAreUnparseable() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/cards/card-3/activities?limit=50",
method = "GET",
status = 200,
responseBody = "{\"data\":\"not-an-array\"}",
)
server.register(
path = "/api/v1/cards/card-3/actions?limit=50",
method = "GET",
status = 200,
responseBody = "{\"actions\":{\"unexpected\":true}}",
)
val secondPayloadItems = (1..12).joinToString(",") { index ->
val day = index.toString().padStart(2, '0')
"""{"id":"a-$index","type":"comment","text":"Item $index","createdAt":"2026-01-${day}T00:00:00Z"}"""
}
server.register(
path = "/api/v1/cards/card-3/card-activities?limit=50",
method = "GET",
status = 200,
responseBody = "{\"data\":[${secondPayloadItems}]}",
)
val result = HttpKanbnApiClient().listCardActivities(server.baseUrl, "api", "card-3")
val activities = (result as BoardsApiResult.Success<List<CardActivity>>).value
assertEquals(10, activities.size)
assertEquals("a-12", activities.first().id)
assertEquals("a-3", activities.last().id)
val requests = server.findRequests("/api/v1/cards/card-3/activities?limit=50") +
server.findRequests("/api/v1/cards/card-3/actions?limit=50") +
server.findRequests("/api/v1/cards/card-3/card-activities?limit=50")
assertEquals(3, requests.size)
assertEquals(
LocalDate.of(2026, 1, 12).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(),
activities.first().createdAtEpochMillis,
)
}
}
@Test
fun addCardComment_continuesFallbackAfter2xxLogicalFailure_untilLaterVariantSucceeds() = runTest {
TestServer().use { server ->
server.registerSequence(
path = "/api/v1/cards/card-4/comment-actions",
method = "POST",
responses = listOf(
200 to "{\"success\":false,\"message\":\"blocked\"}",
500 to "{}",
),
)
server.register(
path = "/api/v1/cards/card-4/actions/comments",
method = "POST",
status = 200,
responseBody = "{\"action\":{\"id\":\"act-1\"}}",
)
val result = HttpKanbnApiClient().addCardComment(server.baseUrl, "api", "card-4", "A comment")
assertTrue(result is BoardsApiResult.Success<*>)
val requests = server.findRequests("/api/v1/cards/card-4/comment-actions")
assertEquals(2, requests.size)
assertEquals("{\"text\":\"A comment\"}", requests[0].body)
assertEquals("{\"comment\":\"A comment\"}", requests[1].body)
val thirdRequests = server.findRequests("/api/v1/cards/card-4/actions/comments")
assertEquals(1, thirdRequests.size)
assertEquals("{\"text\":\"A comment\"}", thirdRequests[0].body)
}
}
@Test
fun addCardComment_fallsBackToThirdEndpoint_andSucceedsOnCommentActionPayload() = runTest {
TestServer().use { server ->
server.register(path = "/api/v1/cards/card-5/comment-actions", method = "POST", status = 500, responseBody = "{}")
server.register(path = "/api/v1/cards/card-5/comment-actions", method = "POST", status = 400, responseBody = "{}")
server.register(
path = "/api/v1/cards/card-5/actions/comments",
method = "POST",
status = 200,
responseBody = "{\"commentAction\":{\"id\":\"x\"}}",
)
val result = HttpKanbnApiClient().addCardComment(server.baseUrl, "api", "card-5", "Looks good")
assertTrue(result is BoardsApiResult.Success<*>)
val endpoint1Requests = server.findRequests("/api/v1/cards/card-5/comment-actions")
assertEquals(2, endpoint1Requests.size)
val endpoint3Requests = server.findRequests("/api/v1/cards/card-5/actions/comments")
assertEquals(1, endpoint3Requests.size)
assertEquals("{\"text\":\"Looks good\"}", endpoint3Requests.first().body)
}
}
private data class CapturedRequest(
val method: String,
val path: String,
val body: String,
)
private class TestServer : AutoCloseable {
private val requests = CopyOnWriteArrayList<CapturedRequest>()
private val responses = mutableMapOf<String, Pair<Int, String>>()
private val responseSequences = mutableMapOf<String, ArrayDeque<Pair<Int, String>>>()
private val running = AtomicBoolean(true)
private val serverSocket = ServerSocket().apply {
bind(InetSocketAddress("127.0.0.1", 0))
}
private val executor = Executors.newSingleThreadExecutor()
val baseUrl: String = "http://127.0.0.1:${serverSocket.localPort}"
init {
executor.execute {
while (running.get()) {
val socket = try {
serverSocket.accept()
} catch (_: Throwable) {
if (!running.get()) {
break
}
continue
}
handle(socket)
}
}
}
fun register(path: String, method: String, status: Int, responseBody: String) {
responses["${method.uppercase()} $path"] = status to responseBody
}
fun registerSequence(path: String, method: String, responses: List<Pair<Int, String>>) {
responseSequences["${method.uppercase()} $path"] = ArrayDeque(responses)
}
fun findRequests(path: String): List<CapturedRequest> {
return requests.filter { it.path == path }
}
private fun handle(socket: Socket) {
socket.use { s ->
s.soTimeout = 3_000
val input = BufferedInputStream(s.getInputStream())
val output = s.getOutputStream()
val requestLine = readHttpLine(input).orEmpty()
if (requestLine.isBlank()) {
return
}
val parts = requestLine.split(" ")
val method = parts.getOrNull(0).orEmpty()
val path = parts.getOrNull(1).orEmpty()
var contentLength = 0
var methodOverride: String? = null
while (true) {
val line = readHttpLine(input).orEmpty()
if (line.isBlank()) {
break
}
val separatorIndex = line.indexOf(':')
if (separatorIndex <= 0) {
continue
}
val headerName = line.substring(0, separatorIndex).trim().lowercase()
val headerValue = line.substring(separatorIndex + 1).trim()
if (headerName == "x-http-method-override") {
methodOverride = headerValue
} else if (headerName == "content-length") {
contentLength = headerValue.toIntOrNull() ?: 0
}
}
val bodyBytes = if (contentLength > 0) ByteArray(contentLength) else ByteArray(0)
if (contentLength > 0) {
var total = 0
while (total < contentLength) {
val read = input.read(bodyBytes, total, contentLength - total)
if (read <= 0) {
break
}
total += read
}
}
val body = String(bodyBytes)
val effectiveMethod = methodOverride ?: method
requests += CapturedRequest(method = effectiveMethod, path = path, body = body)
val sequenceKey = "$effectiveMethod $path"
val sequence = responseSequences[sequenceKey]
val sequencedResponse = if (sequence != null && sequence.isNotEmpty()) sequence.removeFirst() else null
val response = sequencedResponse ?: responses[sequenceKey] ?: (404 to "")
writeResponse(output, response.first, response.second)
}
}
private fun writeResponse(output: OutputStream, status: Int, body: String) {
val bytes = body.toByteArray()
val reason = when (status) {
200 -> "OK"
400 -> "Bad Request"
401 -> "Unauthorized"
404 -> "Not Found"
409 -> "Conflict"
500 -> "Internal Server Error"
else -> "Error"
}
val responseHeaders =
"HTTP/1.1 $status $reason\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: ${bytes.size}\r\n" +
"Connection: close\r\n\r\n"
output.write(responseHeaders.toByteArray())
output.write(bytes)
output.flush()
}
private fun readHttpLine(input: BufferedInputStream): String? {
val builder = StringBuilder()
while (true) {
val next = input.read()
if (next == -1) {
return if (builder.isEmpty()) null else builder.toString()
}
if (next == '\n'.code) {
if (builder.isNotEmpty() && builder.last() == '\r') {
builder.deleteCharAt(builder.length - 1)
}
return builder.toString()
}
builder.append(next.toChar())
}
}
override fun close() {
running.set(false)
serverSocket.close()
executor.shutdownNow()
executor.awaitTermination(3, TimeUnit.SECONDS)
}
}
}

View File

@@ -0,0 +1,861 @@
package space.hackenslacker.kanbn4droid.app.auth
import java.io.BufferedInputStream
import java.io.OutputStream
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.time.Instant
import java.time.LocalDate
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
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
fun getBoardDetailParsesWrappedPayloadWithDueDateVariants() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/boards/board-1",
status = 200,
responseBody =
"""
{
"data": {
"board": {
"public_id": "board-1",
"name": "Roadmap",
"items": [
{
"id": "list-1",
"name": "Todo",
"cards": [
{
"publicId": "card-iso",
"name": "ISO",
"tags": [{"id": "tag-1", "name": "Urgent", "color": "#FF0000"}],
"dueAt": "2026-01-05T08:30:00Z"
},
{
"public_id": "card-epoch-num",
"title": "EpochNum",
"labels": [{"public_id": "tag-2", "title": "Backend", "colorHex": "#00FF00"}],
"due": 1735689600000
}
]
},
{
"publicId": "list-2",
"title": "Doing",
"data": [
{
"id": "card-epoch-string",
"title": "EpochString",
"data": [{"id": "tag-3", "name": "Ops", "hex": "#0000FF"}],
"due_at": "1735689600123"
},
{
"id": "card-invalid",
"name": "Invalid",
"labels": [],
"dueDate": "not-a-date"
}
]
}
]
}
}
}
""".trimIndent(),
)
val client = HttpKanbnApiClient()
val result = client.getBoardDetail(server.baseUrl, "api-key", "board-1")
assertTrue(result is BoardsApiResult.Success<*>)
val detail = (result as BoardsApiResult.Success<*>).value as BoardDetail
assertEquals("board-1", detail.id)
assertEquals("Roadmap", detail.title)
assertEquals(2, detail.lists.size)
assertEquals("list-1", detail.lists[0].id)
assertEquals("Todo", detail.lists[0].title)
assertEquals("card-iso", detail.lists[0].cards[0].id)
assertEquals(Instant.parse("2026-01-05T08:30:00Z").toEpochMilli(), detail.lists[0].cards[0].dueAtEpochMillis)
assertEquals(1735689600000L, detail.lists[0].cards[1].dueAtEpochMillis)
assertEquals("tag-1", detail.lists[0].cards[0].tags[0].id)
assertEquals("Urgent", detail.lists[0].cards[0].tags[0].name)
assertEquals("#FF0000", detail.lists[0].cards[0].tags[0].colorHex)
assertEquals(1735689600123L, detail.lists[1].cards[0].dueAtEpochMillis)
assertNull(detail.lists[1].cards[1].dueAtEpochMillis)
}
}
@Test
fun getBoardDetailParsesDirectRootObject() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/boards/b-2",
status = 200,
responseBody =
"""
{
"id": "b-2",
"title": "Board Direct",
"lists": [
{
"id": "l-1",
"title": "List",
"cards": [
{
"id": "c-1",
"title": "Card",
"labels": [{"id": "t-1", "name": "Tag", "color": "#111111"}]
}
]
}
]
}
""".trimIndent(),
)
val result = HttpKanbnApiClient().getBoardDetail(server.baseUrl, "key", "b-2")
assertTrue(result is BoardsApiResult.Success<*>)
val detail = (result as BoardsApiResult.Success<*>).value as BoardDetail
assertEquals("b-2", detail.id)
assertEquals("Board Direct", detail.title)
assertEquals(1, detail.lists.size)
assertEquals("l-1", detail.lists[0].id)
assertEquals("c-1", detail.lists[0].cards[0].id)
assertEquals("t-1", detail.lists[0].cards[0].tags[0].id)
}
}
@Test
fun getBoardDetailPrefersPublicIdsWhenBothIdFormsExist() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/boards/board-public",
status = 200,
responseBody =
"""
{
"id": "board-internal",
"publicId": "board-public",
"title": "Board",
"lists": [
{
"id": "list-internal",
"public_id": "list-public",
"title": "List",
"cards": [
{
"id": "card-internal",
"publicId": "card-public",
"title": "Card",
"labels": [
{"id": "tag-internal", "publicId": "tag-public", "name": "Tag", "color": "#111111"}
]
}
]
}
]
}
""".trimIndent(),
)
val result = HttpKanbnApiClient().getBoardDetail(server.baseUrl, "key", "board-public")
assertTrue(result is BoardsApiResult.Success<*>)
val detail = (result as BoardsApiResult.Success<*>).value as BoardDetail
assertEquals("board-public", detail.id)
assertEquals("list-public", detail.lists[0].id)
assertEquals("card-public", detail.lists[0].cards[0].id)
assertEquals("tag-public", detail.lists[0].cards[0].tags[0].id)
}
}
@Test
fun getBoardDetailParsesPlainDataWrapperWithoutBoardKey() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/boards/data-only",
status = 200,
responseBody =
"""
{
"data": {
"id": "data-only",
"name": "Wrapped board",
"lists": [
{
"public_id": "list-9",
"name": "Queue",
"cards": [
{
"publicId": "card-99",
"name": "Card in data wrapper",
"labels": [
{"public_id": "tag-77", "title": "Infra", "color": "#ABCDEF"}
]
}
]
}
]
}
}
""".trimIndent(),
)
val result = HttpKanbnApiClient().getBoardDetail(server.baseUrl, "key", "data-only")
assertTrue(result is BoardsApiResult.Success<*>)
val detail = (result as BoardsApiResult.Success<*>).value as BoardDetail
assertEquals("data-only", detail.id)
assertEquals("Wrapped board", detail.title)
assertEquals(1, detail.lists.size)
assertEquals("list-9", detail.lists[0].id)
assertEquals("card-99", detail.lists[0].cards[0].id)
assertEquals("tag-77", detail.lists[0].cards[0].tags[0].id)
}
}
@Test
fun getBoardDetailFailsOnMalformedJsonPayload() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/boards/malformed",
status = 200,
responseBody = "{\"data\": {\"id\": \"broken\"",
)
val result = HttpKanbnApiClient().getBoardDetail(server.baseUrl, "key", "malformed")
assertTrue(result is BoardsApiResult.Failure)
assertEquals(
"Malformed board detail response.",
(result as BoardsApiResult.Failure).message,
)
}
}
@Test
fun requestMappingMatchesContractForBoardDetailAndMutations() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/boards/b-1",
status = 200,
responseBody = """{"id":"b-1","title":"Board","lists":[]}""",
)
server.register(path = "/api/v1/lists/l-1", status = 200, responseBody = "{}")
server.register(path = "/api/v1/cards/c-1", status = 200, responseBody = "{}")
server.register(path = "/api/v1/cards/c-2", status = 200, responseBody = "{}")
val client = HttpKanbnApiClient()
val boardResult = client.getBoardDetail(server.baseUrl, "api-123", "b-1")
val renameResult = client.renameList(server.baseUrl, "api-123", "l-1", " New title ")
val moveResult = client.moveCard(server.baseUrl, "api-123", "c-1", "l-9")
val deleteResult = client.deleteCard(server.baseUrl, "api-123", "c-2")
assertTrue(boardResult is BoardsApiResult.Success<*>)
assertTrue(renameResult is BoardsApiResult.Success<*>)
assertTrue(moveResult is BoardsApiResult.Success<*>)
assertTrue(deleteResult is BoardsApiResult.Success<*>)
val boardRequest = server.findRequest("GET", "/api/v1/boards/b-1")
assertNotNull(boardRequest)
assertEquals("api-123", boardRequest?.apiKey)
val renameRequest = server.findRequest("PATCH", "/api/v1/lists/l-1")
assertNotNull(renameRequest)
assertEquals("{\"name\":\"New title\"}", renameRequest?.body)
assertEquals("api-123", renameRequest?.apiKey)
val moveRequest = server.findRequest("PUT", "/api/v1/cards/c-1")
assertNotNull(moveRequest)
assertEquals("{\"listPublicId\":\"l-9\"}", moveRequest?.body)
assertEquals("api-123", moveRequest?.apiKey)
val deleteRequest = server.findRequest("DELETE", "/api/v1/cards/c-2")
assertNotNull(deleteRequest)
assertEquals("api-123", deleteRequest?.apiKey)
}
}
@Test
fun renameListEscapesControlCharactersInRequestBody() = runTest {
TestServer().use { server ->
server.register(path = "/api/v1/lists/l-esc", status = 200, responseBody = "{}")
val raw = "name\u0000line\u001f\n\tend"
val result = HttpKanbnApiClient().renameList(server.baseUrl, "api", "l-esc", raw)
assertTrue(result is BoardsApiResult.Success<*>)
val request = server.findRequest("PATCH", "/api/v1/lists/l-esc")
assertNotNull(request)
assertEquals(
"{\"name\":\"name\\u0000line\\u001f\\n\\tend\"}",
request?.body,
)
}
}
@Test
fun serverMessageIsPropagatedWithFallbackWhenMissing() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/lists/l-error",
status = 400,
responseBody = """{"message":"List is locked"}""",
)
server.register(
path = "/api/v1/lists/l-fallback",
status = 503,
responseBody = "{}",
)
val client = HttpKanbnApiClient()
val messageResult = client.renameList(server.baseUrl, "api", "l-error", "Name")
val fallbackResult = client.renameList(server.baseUrl, "api", "l-fallback", "Name")
assertTrue(messageResult is BoardsApiResult.Failure)
assertEquals("List is locked", (messageResult as BoardsApiResult.Failure).message)
assertTrue(fallbackResult is BoardsApiResult.Failure)
assertEquals("Server error: 503", (fallbackResult as BoardsApiResult.Failure).message)
}
}
@Test
fun moveCardFailureUsesServerMessageAndFallback() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/cards/c-msg",
status = 409,
responseBody = """{"error":"Card cannot be moved"}""",
)
server.register(
path = "/api/v1/cards/c-fallback",
status = 500,
responseBody = "{}",
)
val client = HttpKanbnApiClient()
val messageResult = client.moveCard(server.baseUrl, "api", "c-msg", "l-1")
val fallbackResult = client.moveCard(server.baseUrl, "api", "c-fallback", "l-1")
assertTrue(messageResult is BoardsApiResult.Failure)
assertEquals("Card cannot be moved", (messageResult as BoardsApiResult.Failure).message)
assertTrue(fallbackResult is BoardsApiResult.Failure)
assertEquals("Server error: 500", (fallbackResult as BoardsApiResult.Failure).message)
}
}
@Test
fun moveCardFallsBackToPutListIdThenPatchWhenPutListPublicIdFails() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/cards/c-fallback-move",
method = "PUT",
status = 400,
responseBody = """{"message":"Failed to update card"}""",
)
server.register(
path = "/api/v1/cards/c-fallback-move",
method = "PATCH",
status = 200,
responseBody = "{}",
)
val result = HttpKanbnApiClient().moveCard(
baseUrl = server.baseUrl,
apiKey = "api",
cardId = "c-fallback-move",
targetListId = "l-target",
)
assertTrue(result is BoardsApiResult.Success<*>)
val putRequests = server.findRequests("PUT", "/api/v1/cards/c-fallback-move")
val patchRequest = server.findRequest("PATCH", "/api/v1/cards/c-fallback-move")
assertEquals(2, putRequests.size)
assertNotNull(patchRequest)
assertEquals("{\"listPublicId\":\"l-target\"}", putRequests[0].body)
assertEquals("{\"listId\":\"l-target\"}", putRequests[1].body)
assertEquals("{\"listId\":\"l-target\"}", patchRequest?.body)
}
}
@Test
fun moveCardUsesFullPutPayloadWhenMinimalPayloadFails() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/cards/c-full",
method = "PUT",
status = 500,
responseBody = """{"message":"Failed to update card"}""",
)
server.register(
path = "/api/v1/cards/c-full",
method = "GET",
status = 200,
responseBody = """{"publicId":"c-full","title":"Card title","description":"Desc","dueDate":null}""",
)
server.registerSequence(
path = "/api/v1/cards/c-full",
method = "PUT",
responses = listOf(
500 to """{"message":"Failed to update card"}""",
200 to "{}",
),
)
val result = HttpKanbnApiClient().moveCard(
baseUrl = server.baseUrl,
apiKey = "api",
cardId = "c-full",
targetListId = "l-target",
)
assertTrue(result is BoardsApiResult.Success<*>)
val putRequests = server.findRequests("PUT", "/api/v1/cards/c-full")
assertTrue(putRequests.size >= 2)
assertEquals("{\"listPublicId\":\"l-target\"}", putRequests[0].body)
assertEquals(
"{\"title\":\"Card title\",\"description\":\"Desc\",\"index\":0,\"listPublicId\":\"l-target\",\"dueDate\":null}",
putRequests[1].body,
)
val getRequest = server.findRequest("GET", "/api/v1/cards/c-full")
assertNotNull(getRequest)
}
}
@Test
fun deleteCardFailureUsesServerMessageAndFallback() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/cards/c-del-msg",
status = 403,
responseBody = """{"detail":"No permission to delete card"}""",
)
server.register(
path = "/api/v1/cards/c-del-fallback",
status = 502,
responseBody = "[]",
)
val client = HttpKanbnApiClient()
val messageResult = client.deleteCard(server.baseUrl, "api", "c-del-msg")
val fallbackResult = client.deleteCard(server.baseUrl, "api", "c-del-fallback")
assertTrue(messageResult is BoardsApiResult.Failure)
assertEquals("No permission to delete card", (messageResult as BoardsApiResult.Failure).message)
assertTrue(fallbackResult is BoardsApiResult.Failure)
assertEquals("Server error: 502", (fallbackResult as BoardsApiResult.Failure).message)
}
}
@Test
fun getLabelByPublicIdParsesColourCodeFromWrappedPayload() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/labels/label-1",
status = 200,
responseBody =
"""
{
"data": {
"label": {
"public_id": "label-1",
"colourCode": "#A1B2C3"
}
}
}
""".trimIndent(),
)
val result = HttpKanbnApiClient().getLabelByPublicId(server.baseUrl, "api-key", "label-1")
assertTrue(result is BoardsApiResult.Success<*>)
val detail = (result as BoardsApiResult.Success<*>).value as LabelDetail
assertEquals("label-1", detail.id)
assertEquals("#A1B2C3", detail.colorHex)
}
}
@Test
fun getLabelByPublicIdUsesFallbackIdWhenPayloadHasNoId() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/labels/label-fallback",
status = 200,
responseBody =
"""
{
"label": {
"colorCode": "#ABCDEF"
}
}
""".trimIndent(),
)
val result = HttpKanbnApiClient().getLabelByPublicId(server.baseUrl, "api-key", "label-fallback")
assertTrue(result is BoardsApiResult.Success<*>)
val detail = (result as BoardsApiResult.Success<*>).value as LabelDetail
assertEquals("label-fallback", detail.id)
assertEquals("#ABCDEF", detail.colorHex)
}
}
@Test
fun getLabelByPublicIdFailureUsesServerMessageAndFallback() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/labels/label-msg",
status = 404,
responseBody = """{"message":"Label not found"}""",
)
server.register(
path = "/api/v1/labels/label-fallback",
status = 500,
responseBody = "{}",
)
val client = HttpKanbnApiClient()
val withMessage = client.getLabelByPublicId(server.baseUrl, "api", "label-msg")
val fallback = client.getLabelByPublicId(server.baseUrl, "api", "label-fallback")
assertTrue(withMessage is BoardsApiResult.Failure)
assertEquals("Label not found", (withMessage as BoardsApiResult.Failure).message)
assertTrue(fallback is BoardsApiResult.Failure)
assertEquals("Server error: 500", (fallback as BoardsApiResult.Failure).message)
}
}
private data class CapturedRequest(
val method: String,
val path: String,
val body: String,
val apiKey: String?,
)
private class TestServer : AutoCloseable {
private val requests = CopyOnWriteArrayList<CapturedRequest>()
private val responses = mutableMapOf<String, Pair<Int, String>>()
private val responseSequences = mutableMapOf<String, ArrayDeque<Pair<Int, String>>>()
private val running = AtomicBoolean(true)
private val serverSocket = ServerSocket().apply {
bind(InetSocketAddress("127.0.0.1", 0))
}
private val executor = Executors.newSingleThreadExecutor()
val baseUrl: String = "http://127.0.0.1:${serverSocket.localPort}"
init {
executor.execute {
while (running.get()) {
val socket = try {
serverSocket.accept()
} catch (_: Throwable) {
if (!running.get()) {
break
}
continue
}
handle(socket)
}
}
}
fun register(path: String, status: Int, responseBody: String) {
register(path = path, method = "GET", status = status, responseBody = responseBody)
register(path = path, method = "PATCH", status = status, responseBody = responseBody)
register(path = path, method = "PUT", status = status, responseBody = responseBody)
register(path = path, method = "DELETE", status = status, responseBody = responseBody)
register(path = path, method = "POST", status = status, responseBody = responseBody)
}
fun register(path: String, method: String, status: Int, responseBody: String) {
responses["${method.uppercase()} $path"] = status to responseBody
}
fun registerSequence(path: String, method: String, responses: List<Pair<Int, String>>) {
responseSequences["${method.uppercase()} $path"] = ArrayDeque(responses)
}
fun findRequest(method: String, path: String): CapturedRequest? {
return requests.firstOrNull { it.method == method && it.path == path }
}
fun findRequests(method: String, path: String): List<CapturedRequest> {
return requests.filter { it.method == method && it.path == path }
}
private fun handle(socket: Socket) {
socket.use { s ->
s.soTimeout = 3_000
val input = BufferedInputStream(s.getInputStream())
val output = s.getOutputStream()
val requestLine = readHttpLine(input).orEmpty()
if (requestLine.isBlank()) {
return
}
val parts = requestLine.split(" ")
val method = parts.getOrNull(0).orEmpty()
val path = parts.getOrNull(1).orEmpty()
var apiKey: String? = null
var contentLength = 0
var methodOverride: String? = null
while (true) {
val line = readHttpLine(input).orEmpty()
if (line.isBlank()) {
break
}
val separatorIndex = line.indexOf(':')
if (separatorIndex <= 0) {
continue
}
val headerName = line.substring(0, separatorIndex).trim().lowercase()
val headerValue = line.substring(separatorIndex + 1).trim()
if (headerName == "x-api-key") {
apiKey = headerValue
} else if (headerName == "x-http-method-override") {
methodOverride = headerValue
} else if (headerName == "content-length") {
contentLength = headerValue.toIntOrNull() ?: 0
}
}
val bodyBytes = if (contentLength > 0) ByteArray(contentLength) else ByteArray(0)
if (contentLength > 0) {
var total = 0
while (total < contentLength) {
val read = input.read(bodyBytes, total, contentLength - total)
if (read <= 0) {
break
}
total += read
}
}
val body = String(bodyBytes)
val effectiveMethod = methodOverride ?: method
requests += CapturedRequest(method = effectiveMethod, path = path, body = body, apiKey = apiKey)
val sequenceKey = "$effectiveMethod $path"
val sequence = responseSequences[sequenceKey]
val sequencedResponse = if (sequence != null && sequence.isNotEmpty()) sequence.removeFirst() else null
val response = sequencedResponse ?: responses[sequenceKey] ?: responses["$method $path"] ?: (404 to "")
writeResponse(output, response.first, response.second)
}
}
private fun writeResponse(output: OutputStream, status: Int, body: String) {
val bytes = body.toByteArray()
val reason = when (status) {
200 -> "OK"
400 -> "Bad Request"
403 -> "Forbidden"
409 -> "Conflict"
404 -> "Not Found"
500 -> "Internal Server Error"
502 -> "Bad Gateway"
503 -> "Service Unavailable"
else -> "Error"
}
val responseHeaders =
"HTTP/1.1 $status $reason\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: ${bytes.size}\r\n" +
"Connection: close\r\n\r\n"
output.write(responseHeaders.toByteArray())
output.write(bytes)
output.flush()
}
private fun readHttpLine(input: BufferedInputStream): String? {
val builder = StringBuilder()
while (true) {
val next = input.read()
if (next == -1) {
return if (builder.isEmpty()) null else builder.toString()
}
if (next == '\n'.code) {
if (builder.isNotEmpty() && builder.last() == '\r') {
builder.deleteCharAt(builder.length - 1)
}
return builder.toString()
}
builder.append(next.toChar())
}
}
override fun close() {
running.set(false)
serverSocket.close()
executor.shutdownNow()
executor.awaitTermination(3, TimeUnit.SECONDS)
}
}
}

View File

@@ -0,0 +1,251 @@
package space.hackenslacker.kanbn4droid.app.auth
import java.io.BufferedInputStream
import java.io.OutputStream
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
import space.hackenslacker.kanbn4droid.app.boards.DrawerProfile
class HttpKanbnApiClientUsersMeParsingTest {
@Test
fun getCurrentUser_parsesProfileFromWrappedPayload() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/users/me",
method = "GET",
status = 200,
responseBody =
"""
{
"data": {
"user": {
"name": "Alice",
"email": "alice@example.com"
}
}
}
""".trimIndent(),
)
val result = HttpKanbnApiClient().getCurrentUser(server.baseUrl, "api")
assertTrue(result is BoardsApiResult.Success<*>)
val profile = (result as BoardsApiResult.Success<*>).value as DrawerProfile
assertEquals("Alice", profile.displayName)
assertEquals("alice@example.com", profile.email)
}
}
@Test
fun getCurrentUser_usesFallbackKeys_usernameNameEmail() = runTest {
TestServer().use { server ->
server.registerSequence(
path = "/api/v1/users/me",
method = "GET",
responses = listOf(
200 to """{"username":"alpha","email":"a@example.com"}""",
200 to """{"name":"beta","email":"b@example.com"}""",
200 to """{"email":"c@example.com"}""",
),
)
val client = HttpKanbnApiClient()
val first = client.getCurrentUser(server.baseUrl, "api")
val second = client.getCurrentUser(server.baseUrl, "api")
val third = client.getCurrentUser(server.baseUrl, "api")
assertEquals("alpha", (first as BoardsApiResult.Success<DrawerProfile>).value.displayName)
assertEquals("beta", (second as BoardsApiResult.Success<DrawerProfile>).value.displayName)
assertEquals("c@example.com", (third as BoardsApiResult.Success<DrawerProfile>).value.displayName)
}
}
@Test
fun getCurrentUser_usesServerMessageOnFailure() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/users/me",
method = "GET",
status = 403,
responseBody = """{"message":"Invalid API key"}""",
)
val result = HttpKanbnApiClient().getCurrentUser(server.baseUrl, "api")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("Invalid API key", (result as BoardsApiResult.Failure).message)
}
}
@Test
fun getCurrentUser_requestMappingUsesExpectedEndpointAndApiKeyHeader() = runTest {
TestServer().use { server ->
server.register(path = "/api/v1/users/me", method = "GET", status = 200, responseBody = """{"username":"mapped"}""")
val result = HttpKanbnApiClient().getCurrentUser(server.baseUrl, "api-123")
assertTrue(result is BoardsApiResult.Success<*>)
val request = server.findRequest("GET", "/api/v1/users/me")
assertNotNull(request)
assertEquals("api-123", request?.apiKey)
}
}
private data class CapturedRequest(
val method: String,
val path: String,
val body: String,
val apiKey: String?,
)
private class TestServer : AutoCloseable {
private val requests = CopyOnWriteArrayList<CapturedRequest>()
private val responses = mutableMapOf<String, Pair<Int, String>>()
private val responseSequences = mutableMapOf<String, ArrayDeque<Pair<Int, String>>>()
private val running = AtomicBoolean(true)
private val serverSocket = ServerSocket().apply {
bind(InetSocketAddress("127.0.0.1", 0))
}
private val executor = Executors.newSingleThreadExecutor()
val baseUrl: String = "http://127.0.0.1:${serverSocket.localPort}"
init {
executor.execute {
while (running.get()) {
val socket = try {
serverSocket.accept()
} catch (_: Throwable) {
if (!running.get()) {
break
}
continue
}
handle(socket)
}
}
}
fun register(path: String, method: String, status: Int, responseBody: String) {
responses["${method.uppercase()} $path"] = status to responseBody
}
fun registerSequence(path: String, method: String, responses: List<Pair<Int, String>>) {
responseSequences["${method.uppercase()} $path"] = ArrayDeque(responses)
}
fun findRequest(method: String, path: String): CapturedRequest? {
return requests.firstOrNull { it.method == method && it.path == path }
}
private fun handle(socket: Socket) {
socket.use { s ->
s.soTimeout = 3_000
val input = BufferedInputStream(s.getInputStream())
val output = s.getOutputStream()
val requestLine = readHttpLine(input).orEmpty()
if (requestLine.isBlank()) {
return
}
val parts = requestLine.split(" ")
val method = parts.getOrNull(0).orEmpty()
val path = parts.getOrNull(1).orEmpty()
var apiKey: String? = null
var contentLength = 0
while (true) {
val line = readHttpLine(input).orEmpty()
if (line.isBlank()) {
break
}
val separatorIndex = line.indexOf(':')
if (separatorIndex <= 0) {
continue
}
val headerName = line.substring(0, separatorIndex).trim().lowercase()
val headerValue = line.substring(separatorIndex + 1).trim()
if (headerName == "x-api-key") {
apiKey = headerValue
} else if (headerName == "content-length") {
contentLength = headerValue.toIntOrNull() ?: 0
}
}
val bodyBytes = if (contentLength > 0) ByteArray(contentLength) else ByteArray(0)
if (contentLength > 0) {
var total = 0
while (total < contentLength) {
val read = input.read(bodyBytes, total, contentLength - total)
if (read <= 0) {
break
}
total += read
}
}
val body = String(bodyBytes)
requests += CapturedRequest(method = method, path = path, body = body, apiKey = apiKey)
val sequenceKey = "$method $path"
val sequence = responseSequences[sequenceKey]
val sequencedResponse = if (sequence != null && sequence.isNotEmpty()) sequence.removeFirst() else null
val response = sequencedResponse ?: responses[sequenceKey] ?: (404 to "")
writeResponse(output, response.first, response.second)
}
}
private fun writeResponse(output: OutputStream, status: Int, body: String) {
val bytes = body.toByteArray()
val reason = when (status) {
200 -> "OK"
403 -> "Forbidden"
404 -> "Not Found"
else -> "Error"
}
val responseHeaders =
"HTTP/1.1 $status $reason\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: ${bytes.size}\r\n" +
"Connection: close\r\n\r\n"
output.write(responseHeaders.toByteArray())
output.write(bytes)
output.flush()
}
private fun readHttpLine(input: BufferedInputStream): String? {
val builder = StringBuilder()
while (true) {
val next = input.read()
if (next == -1) {
return if (builder.isEmpty()) null else builder.toString()
}
if (next == '\n'.code) {
if (builder.isNotEmpty() && builder.last() == '\r') {
builder.deleteCharAt(builder.length - 1)
}
return builder.toString()
}
builder.append(next.toChar())
}
}
override fun close() {
running.set(false)
serverSocket.close()
executor.shutdownNow()
executor.awaitTermination(3, TimeUnit.SECONDS)
}
}
}

View File

@@ -0,0 +1,359 @@
package space.hackenslacker.kanbn4droid.app.auth
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class SettingsApplyCoordinatorTest {
@Test
fun applyNoChangesReturnsNoChangesWithoutAuthCall() = runTest {
val sessionStore = FakeSessionStore().apply {
saveDraftApiKey("truth-key")
saveApiKey("session-key")
}
val apiClient = FakeKanbnApiClient()
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "truth-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertEquals(SettingsApplyResult.NoChanges, result)
assertEquals(0, apiClient.healthCheckCalls.size)
assertEquals(1, apiKeyStore.getCalls.size)
assertTrue(apiKeyStore.saveCalls.isEmpty())
assertTrue(apiKeyStore.invalidateCalls.isEmpty())
}
@Test
fun applyCredentialChangeSuccessPersistsAndReturnsSuccessCredentialChange() = runTest {
val sessionStore = FakeSessionStore().apply {
saveDraftBaseUrl("https://next.kan.bn")
saveDraftApiKey("next-key")
}
val apiClient = FakeKanbnApiClient(
healthCheckResult = AuthResult.Success,
)
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "existing-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertEquals(SettingsApplyResult.SuccessCredentialChange, result)
assertEquals(1, apiClient.healthCheckCalls.size)
assertEquals("https://next.kan.bn/", apiClient.healthCheckCalls.single().baseUrl)
assertEquals("next-key", apiClient.healthCheckCalls.single().apiKey)
assertEquals("https://next.kan.bn/", sessionStore.getBaseUrl())
assertEquals("next-key", sessionStore.getApiKey())
assertEquals(sessionStore.getBaseUrl(), sessionStore.getDraftBaseUrl())
assertEquals(sessionStore.getApiKey(), sessionStore.getDraftApiKey())
assertEquals("next-key", apiKeyStore.peek("https://next.kan.bn/"))
assertEquals(null, apiKeyStore.peek("https://kan.bn/"))
assertEquals(1, apiKeyStore.saveCalls.size)
assertEquals(1, apiKeyStore.invalidateCalls.size)
}
@Test
fun applyAuthFailureRollsBackCommittedAndDraftValues() = runTest {
val sessionStore = FakeSessionStore().apply {
saveDraftBaseUrl("https://broken.kan.bn")
saveDraftApiKey("bad-key")
}
val apiClient = FakeKanbnApiClient(
healthCheckResult = AuthResult.Failure(
message = "Authentication failed. Check your API key.",
reason = AuthFailureReason.Authentication,
),
)
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "existing-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertEquals(
SettingsApplyResult.AuthError("Authentication failed. Check your API key."),
result,
)
assertEquals("https://kan.bn/", sessionStore.getBaseUrl())
assertEquals("existing-key", sessionStore.getApiKey())
assertEquals("https://kan.bn/", sessionStore.getDraftBaseUrl())
assertEquals("existing-key", sessionStore.getDraftApiKey())
assertEquals("existing-key", apiKeyStore.peek("https://kan.bn/"))
assertEquals(null, apiKeyStore.peek("https://broken.kan.bn/"))
}
@Test
fun applyPersistFailureAfterAuthRollsBack() = runTest {
val sessionStore = FakeSessionStore(
failSyncToCommitted = true,
).apply {
saveDraftBaseUrl("https://next.kan.bn")
saveDraftApiKey("next-key")
}
val apiClient = FakeKanbnApiClient(
healthCheckResult = AuthResult.Success,
)
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "existing-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertTrue(result is SettingsApplyResult.NetworkError)
result as SettingsApplyResult.NetworkError
assertTrue(result.message.contains("apply", ignoreCase = true))
assertEquals("https://kan.bn/", sessionStore.getBaseUrl())
assertEquals("existing-key", sessionStore.getApiKey())
assertEquals("https://kan.bn/", sessionStore.getDraftBaseUrl())
assertEquals("existing-key", sessionStore.getDraftApiKey())
assertEquals("existing-key", apiKeyStore.peek("https://kan.bn/"))
assertEquals(null, apiKeyStore.peek("https://next.kan.bn/"))
}
@Test
fun applyInvalidUrlReturnsValidationError() = runTest {
val sessionStore = FakeSessionStore().apply {
saveDraftBaseUrl("ftp://kan.bn")
}
val apiClient = FakeKanbnApiClient()
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "existing-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertEquals(
SettingsApplyResult.ValidationError(
field = "baseUrl",
message = "Base URL must start with http:// or https://",
),
result,
)
assertEquals(0, apiClient.healthCheckCalls.size)
assertEquals("https://kan.bn/", sessionStore.getDraftBaseUrl())
assertEquals("existing-key", sessionStore.getDraftApiKey())
assertTrue(apiKeyStore.saveCalls.isEmpty())
assertTrue(apiKeyStore.invalidateCalls.isEmpty())
}
@Test
fun applyBlankApiKeyReturnsValidationErrorForApiKey() = runTest {
val sessionStore = FakeSessionStore().apply {
saveDraftApiKey(" ")
}
val apiClient = FakeKanbnApiClient()
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "existing-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertEquals(
SettingsApplyResult.ValidationError(
field = "apiKey",
message = "API key is required",
),
result,
)
assertEquals(0, apiClient.healthCheckCalls.size)
assertEquals("existing-key", sessionStore.getDraftApiKey())
assertTrue(apiKeyStore.saveCalls.isEmpty())
assertTrue(apiKeyStore.invalidateCalls.isEmpty())
}
@Test
fun applyThemeAndCredentialsFailureRollsBackThemeDraft() = runTest {
val sessionStore = FakeSessionStore().apply {
saveDraftThemeMode("dark")
saveDraftBaseUrl("https://next.kan.bn")
saveDraftApiKey("next-key")
}
val apiClient = FakeKanbnApiClient(
healthCheckResult = AuthResult.Failure(
message = "Cannot reach server. Check your connection and URL.",
reason = AuthFailureReason.Connectivity,
),
)
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "existing-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertEquals(
SettingsApplyResult.NetworkError("Cannot reach server. Check your connection and URL."),
result,
)
assertEquals("system", sessionStore.getThemeMode())
assertEquals("system", sessionStore.getDraftThemeMode())
assertEquals("existing-key", apiKeyStore.peek("https://kan.bn/"))
assertEquals(null, apiKeyStore.peek("https://next.kan.bn/"))
}
@Test
fun applyThemeChangeSuccessSignalsThemeMode() = runTest {
val sessionStore = FakeSessionStore().apply {
saveDraftThemeMode("dark")
}
val apiClient = FakeKanbnApiClient()
val apiKeyStore = FakeApiKeyStore().apply {
setKey("https://kan.bn/", "existing-key")
}
val coordinator = SettingsApplyCoordinator(sessionStore, apiClient, apiKeyStore)
val result = coordinator.apply()
assertEquals(SettingsApplyResult.SuccessNoCredentialChange(themeChanged = true), result)
assertEquals(0, apiClient.healthCheckCalls.size)
assertEquals("dark", sessionStore.getThemeMode())
assertEquals("dark", sessionStore.getDraftThemeMode())
assertTrue(apiKeyStore.saveCalls.isEmpty())
assertTrue(apiKeyStore.invalidateCalls.isEmpty())
}
private class FakeSessionStore(
private val failSyncToCommitted: Boolean = false,
) : SessionStore {
private var committedThemeMode: String = "system"
private var committedBaseUrl: String? = "https://kan.bn/"
private var committedApiKey: String? = "existing-key"
private var draftThemeMode: String = committedThemeMode
private var draftBaseUrl: String? = committedBaseUrl
private var draftApiKey: String? = committedApiKey
override fun getThemeMode(): String = committedThemeMode
override fun saveThemeMode(themeMode: String) {
committedThemeMode = themeMode
}
override fun getBaseUrl(): String? = committedBaseUrl
override fun saveBaseUrl(url: String) {
committedBaseUrl = url
}
override fun getApiKey(): String? = committedApiKey
override fun saveApiKey(apiKey: String) {
committedApiKey = apiKey
}
override fun clearApiKey() {
committedApiKey = null
}
override fun getWorkspaceId(): String? = null
override fun saveWorkspaceId(workspaceId: String) {
}
override fun clearBaseUrl() {
committedBaseUrl = null
}
override fun clearWorkspaceId() {
}
override fun initializeDraftsFromCommitted() {
draftThemeMode = committedThemeMode
draftBaseUrl = committedBaseUrl
draftApiKey = committedApiKey
}
override fun getDraftThemeMode(): String = draftThemeMode
override fun saveDraftThemeMode(themeMode: String) {
draftThemeMode = themeMode
}
override fun getDraftBaseUrl(): String? = draftBaseUrl
override fun saveDraftBaseUrl(url: String) {
draftBaseUrl = url
}
override fun getDraftApiKey(): String? = draftApiKey
override fun saveDraftApiKey(apiKey: String) {
draftApiKey = apiKey
}
override fun resetDraftsFromCommitted() {
initializeDraftsFromCommitted()
}
override fun syncDraftsToCommitted() {
committedThemeMode = draftThemeMode
committedBaseUrl = draftBaseUrl
committedApiKey = draftApiKey
if (failSyncToCommitted) {
throw IllegalStateException("sync failed")
}
}
}
private class FakeKanbnApiClient(
private val healthCheckResult: AuthResult = AuthResult.Success,
) : KanbnApiClient {
val healthCheckCalls = mutableListOf<HealthCheckCall>()
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult {
healthCheckCalls += HealthCheckCall(baseUrl = baseUrl, apiKey = apiKey)
return healthCheckResult
}
}
private data class HealthCheckCall(
val baseUrl: String,
val apiKey: String,
)
private class FakeApiKeyStore : ApiKeyStore {
private val keysByBaseUrl = mutableMapOf<String, String>()
val getCalls = mutableListOf<String>()
val saveCalls = mutableListOf<SaveCall>()
val invalidateCalls = mutableListOf<String>()
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
saveCalls += SaveCall(baseUrl = baseUrl, apiKey = apiKey)
keysByBaseUrl[baseUrl] = apiKey
return Result.success(Unit)
}
override suspend fun getApiKey(baseUrl: String): Result<String?> {
getCalls += baseUrl
return Result.success(keysByBaseUrl[baseUrl])
}
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
invalidateCalls += baseUrl
keysByBaseUrl.remove(baseUrl)
return Result.success(Unit)
}
fun setKey(baseUrl: String, apiKey: String) {
keysByBaseUrl[baseUrl] = apiKey
}
fun peek(baseUrl: String): String? = keysByBaseUrl[baseUrl]
}
private data class SaveCall(
val baseUrl: String,
val apiKey: String,
)
}

View File

@@ -0,0 +1,67 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
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
fun boardDetailModelsExposeRequiredFields() {
val tag = BoardTagSummary(
id = "tag-1",
name = "Urgent",
colorHex = "#FF0000",
)
val card = BoardCardSummary(
id = "card-1",
title = "Fix sync bug",
tags = listOf(tag),
dueAtEpochMillis = null,
)
val list = BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(card),
)
val detail = BoardDetail(
id = "board-1",
title = "Sprint",
lists = listOf(list),
)
assertEquals("tag-1", tag.id)
assertEquals("Urgent", tag.name)
assertEquals("#FF0000", tag.colorHex)
assertEquals("card-1", card.id)
assertEquals("Fix sync bug", card.title)
assertEquals(listOf(tag), card.tags)
assertNull(card.dueAtEpochMillis)
assertEquals("list-1", list.id)
assertEquals("To Do", list.title)
assertEquals(listOf(card), list.cards)
assertEquals("board-1", detail.id)
assertEquals("Sprint", detail.title)
assertEquals(listOf(list), detail.lists)
}
@Test
fun partialSuccessCarriesFailedCardIds() {
val result = CardBatchMutationResult.PartialSuccess(
failedCardIds = setOf("card-2", "card-9"),
message = "Some cards could not be updated.",
)
assertEquals(setOf("card-2", "card-9"), result.failedCardIds)
assertEquals("Some cards could not be updated.", result.message)
}
}

View File

@@ -0,0 +1,760 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import kotlinx.coroutines.test.runTest
import java.time.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.LabelDetail
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary
class BoardDetailRepositoryTest {
@Test
fun getBoardDetailUsesStoredWorkspaceWhenPresent() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
boardDetailResult = BoardsApiResult.Success(sampleBoardDetail())
}
val sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-stored")
val repository = createRepository(sessionStore = sessionStore, apiClient = apiClient)
val result = repository.getBoardDetail("board-1")
assertTrue(result is BoardsApiResult.Success<*>)
assertEquals(0, apiClient.listWorkspacesCalls)
assertEquals("board-1", apiClient.lastBoardId)
}
@Test
fun getBoardDetailFetchesAndPersistsWorkspaceWhenMissing() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
boardDetailResult = BoardsApiResult.Success(sampleBoardDetail())
}
val sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/")
val repository = createRepository(sessionStore = sessionStore, apiClient = apiClient)
val result = repository.getBoardDetail("board-1")
assertTrue(result is BoardsApiResult.Success<*>)
assertEquals(1, apiClient.listWorkspacesCalls)
assertEquals("ws-1", sessionStore.getWorkspaceId())
assertEquals("board-1", apiClient.lastBoardId)
}
@Test
fun getBoardDetailReusesPersistedWorkspaceAfterFirstFetch() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
boardDetailResult = BoardsApiResult.Success(sampleBoardDetail())
}
val sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/")
val repository = createRepository(sessionStore = sessionStore, apiClient = apiClient)
val firstResult = repository.getBoardDetail("board-1")
apiClient.workspacesResult = BoardsApiResult.Failure("Should not be called")
val secondResult = repository.getBoardDetail("board-2")
assertTrue(firstResult is BoardsApiResult.Success<*>)
assertTrue(secondResult is BoardsApiResult.Success<*>)
assertEquals("ws-1", sessionStore.getWorkspaceId())
assertEquals(1, apiClient.listWorkspacesCalls)
assertEquals("board-2", apiClient.lastBoardId)
}
@Test
fun getBoardDetailFailsWhenNoWorkspacesAvailable() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
workspacesResult = BoardsApiResult.Success(emptyList())
}
val repository = createRepository(
sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/"),
apiClient = apiClient,
)
val result = repository.getBoardDetail("board-1")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("No workspaces available for this account.", (result as BoardsApiResult.Failure).message)
}
@Test
fun getBoardDetailPropagatesApiFailureMessageUnchanged() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
boardDetailResult = BoardsApiResult.Failure("Server says no")
}
val repository = createRepository(
sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-1"),
apiClient = apiClient,
)
val result = repository.getBoardDetail("board-1")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("Server says no", (result as BoardsApiResult.Failure).message)
}
@Test
fun getBoardDetailFetchesLabelColorCodesAndCachesThem() = runTest {
val detailWithTags = BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Card 1",
tags = listOf(
BoardTagSummary(id = "label-1", name = "Urgent", colorHex = ""),
BoardTagSummary(id = "label-2", name = "Backend", colorHex = "#000000"),
),
dueAtEpochMillis = null,
),
BoardCardSummary(
id = "card-2",
title = "Card 2",
tags = listOf(
BoardTagSummary(id = "label-1", name = "Urgent", colorHex = ""),
),
dueAtEpochMillis = null,
),
),
),
),
)
val apiClient = FakeBoardDetailApiClient().apply {
boardDetailResult = BoardsApiResult.Success(detailWithTags)
labelByIdResults = mapOf(
"label-1" to BoardsApiResult.Success(LabelDetail("label-1", "#112233")),
"label-2" to BoardsApiResult.Success(LabelDetail("label-2", "#445566")),
)
}
val repository = createRepository(apiClient = apiClient)
val first = repository.getBoardDetail("board-1")
val second = repository.getBoardDetail("board-1")
assertTrue(first is BoardsApiResult.Success<*>)
assertTrue(second is BoardsApiResult.Success<*>)
val firstDetail = (first as BoardsApiResult.Success<BoardDetail>).value
val secondDetail = (second as BoardsApiResult.Success<BoardDetail>).value
assertEquals("#112233", firstDetail.lists[0].cards[0].tags[0].colorHex)
assertEquals("#445566", firstDetail.lists[0].cards[0].tags[1].colorHex)
assertEquals("#112233", firstDetail.lists[0].cards[1].tags[0].colorHex)
assertEquals("#112233", secondDetail.lists[0].cards[0].tags[0].colorHex)
assertEquals("#445566", secondDetail.lists[0].cards[0].tags[1].colorHex)
assertEquals(listOf("label-1", "label-2"), apiClient.getLabelByPublicIdCalls)
}
@Test
fun getBoardDetailKeepsOriginalColorWhenLabelLookupFails() = runTest {
val detailWithTag = BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Card 1",
tags = listOf(BoardTagSummary(id = "label-1", name = "Urgent", colorHex = "#ABCDEF")),
dueAtEpochMillis = null,
),
),
),
),
)
val apiClient = FakeBoardDetailApiClient().apply {
boardDetailResult = BoardsApiResult.Success(detailWithTag)
labelByIdResults = mapOf("label-1" to BoardsApiResult.Failure("Server unavailable"))
}
val repository = createRepository(apiClient = apiClient)
val result = repository.getBoardDetail("board-1")
assertTrue(result is BoardsApiResult.Success<*>)
val detail = (result as BoardsApiResult.Success<BoardDetail>).value
assertEquals("#ABCDEF", detail.lists[0].cards[0].tags[0].colorHex)
assertEquals(listOf("label-1"), apiClient.getLabelByPublicIdCalls)
}
@Test
fun renameListPropagatesApiFailureMessageUnchanged() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
renameListResult = BoardsApiResult.Failure("List cannot be renamed")
}
val repository = createRepository(
sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-1"),
apiClient = apiClient,
)
val result = repository.renameList("list-1", "New title")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("List cannot be renamed", (result as BoardsApiResult.Failure).message)
assertEquals("list-1", apiClient.lastListId)
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
fun getBoardDetailValidatesBoardId() = runTest {
val repository = createRepository()
val result = repository.getBoardDetail(" ")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("Board id is required", (result as BoardsApiResult.Failure).message)
}
@Test
fun renameListValidatesListId() = runTest {
val repository = createRepository()
val result = repository.renameList(" ", "Some title")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("List id is required", (result as BoardsApiResult.Failure).message)
}
@Test
fun renameListValidatesTitle() = runTest {
val repository = createRepository()
val result = repository.renameList("list-1", " ")
assertTrue(result is BoardsApiResult.Failure)
assertEquals("List title is required", (result as BoardsApiResult.Failure).message)
}
@Test
fun moveCardsValidatesTargetListId() = runTest {
val repository = createRepository()
val result = repository.moveCards(cardIds = listOf("card-1"), targetListId = " ")
assertTrue(result is CardBatchMutationResult.Failure)
assertEquals("Target list id is required", (result as CardBatchMutationResult.Failure).message)
}
@Test
fun moveCardsValidatesCardIds() = runTest {
val repository = createRepository()
val result = repository.moveCards(cardIds = listOf(" ", ""), targetListId = "list-2")
assertTrue(result is CardBatchMutationResult.Failure)
assertEquals("At least one card id is required", (result as CardBatchMutationResult.Failure).message)
}
@Test
fun deleteCardsValidatesCardIds() = runTest {
val repository = createRepository()
val result = repository.deleteCards(cardIds = listOf(" ", ""))
assertTrue(result is CardBatchMutationResult.Failure)
assertEquals("At least one card id is required", (result as CardBatchMutationResult.Failure).message)
}
@Test
fun moveCardsReturnsSuccessWhenAllMutationsSucceed() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
moveOutcomes = mapOf(
"card-1" to BoardsApiResult.Success(Unit),
"card-2" to BoardsApiResult.Success(Unit),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.moveCards(
cardIds = listOf(" card-1 ", "", "card-1", "card-2"),
targetListId = " list-target ",
)
assertEquals(CardBatchMutationResult.Success, result)
assertEquals(listOf("card-1", "card-2"), apiClient.movedCardIds)
assertEquals("list-target", apiClient.lastMoveTargetListId)
}
@Test
fun moveCardsReturnsPartialSuccessWhenSomeMutationsFail() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
moveOutcomes = mapOf(
"card-1" to BoardsApiResult.Success(Unit),
"card-2" to BoardsApiResult.Failure("Cannot move"),
"card-3" to BoardsApiResult.Success(Unit),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.moveCards(
cardIds = listOf("card-1", " card-2 ", "card-3", "card-2"),
targetListId = "list-target",
)
assertTrue(result is CardBatchMutationResult.PartialSuccess)
assertEquals(setOf("card-2"), (result as CardBatchMutationResult.PartialSuccess).failedCardIds)
assertEquals("Some cards could not be moved. Please try again.", result.message)
assertEquals(listOf("card-1", "card-2", "card-3"), apiClient.movedCardIds)
}
@Test
fun moveCardsReturnsFailureWithFirstNormalizedMessageWhenAllFail() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
moveOutcomes = mapOf(
"card-2" to BoardsApiResult.Failure(" "),
"card-1" to BoardsApiResult.Failure("Second failure"),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.moveCards(
cardIds = listOf(" card-2 ", "card-1", "card-2"),
targetListId = "list-target",
)
assertTrue(result is CardBatchMutationResult.Failure)
assertEquals("Unknown error", (result as CardBatchMutationResult.Failure).message)
assertEquals(listOf("card-2", "card-1"), apiClient.movedCardIds)
}
@Test
fun deleteCardsReturnsSuccessWhenAllMutationsSucceed() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
deleteOutcomes = mapOf(
"card-1" to BoardsApiResult.Success(Unit),
"card-2" to BoardsApiResult.Success(Unit),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.deleteCards(cardIds = listOf("card-1", " card-2 ", "card-1"))
assertEquals(CardBatchMutationResult.Success, result)
assertEquals(listOf("card-1", "card-2"), apiClient.deletedCardIds)
}
@Test
fun deleteCardsReturnsPartialSuccessWhenSomeMutationsFail() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
deleteOutcomes = mapOf(
"card-1" to BoardsApiResult.Success(Unit),
"card-2" to BoardsApiResult.Failure("Cannot delete"),
"card-3" to BoardsApiResult.Success(Unit),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.deleteCards(cardIds = listOf("card-1", " card-2 ", "card-3", "card-2"))
assertTrue(result is CardBatchMutationResult.PartialSuccess)
assertEquals(setOf("card-2"), (result as CardBatchMutationResult.PartialSuccess).failedCardIds)
assertEquals("Some cards could not be deleted. Please try again.", result.message)
assertEquals(listOf("card-1", "card-2", "card-3"), apiClient.deletedCardIds)
}
@Test
fun deleteCardsReturnsFailureWithFirstNormalizedMessageWhenAllFail() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
deleteOutcomes = mapOf(
"card-2" to BoardsApiResult.Failure("Delete failed first"),
"card-1" to BoardsApiResult.Failure("Delete failed second"),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.deleteCards(cardIds = listOf(" card-2 ", "card-1", "card-2"))
assertTrue(result is CardBatchMutationResult.Failure)
assertEquals("Delete failed first", (result as CardBatchMutationResult.Failure).message)
assertEquals(listOf("card-2", "card-1"), apiClient.deletedCardIds)
}
@Test
fun deleteCardsReturnsUnknownErrorWhenAllFailAndFirstMessageIsBlank() = runTest {
val apiClient = FakeBoardDetailApiClient().apply {
deleteOutcomes = mapOf(
"card-2" to BoardsApiResult.Failure(" "),
"card-1" to BoardsApiResult.Failure("Delete failed second"),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.deleteCards(cardIds = listOf(" card-2 ", "card-1", "card-2"))
assertTrue(result is CardBatchMutationResult.Failure)
assertEquals("Unknown error", (result as CardBatchMutationResult.Failure).message)
assertEquals(listOf("card-2", "card-1"), apiClient.deletedCardIds)
}
private fun createRepository(
sessionStore: InMemorySessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-1"),
apiClient: FakeBoardDetailApiClient = FakeBoardDetailApiClient(),
apiKeyStore: InMemoryApiKeyStore = InMemoryApiKeyStore("api-key"),
): BoardDetailRepository {
return BoardDetailRepository(
sessionStore = sessionStore,
apiKeyStore = apiKeyStore,
apiClient = apiClient,
)
}
private class InMemorySessionStore(
private var baseUrl: String?,
private var workspaceId: String? = null,
) : SessionStore {
override fun getBaseUrl(): String? = baseUrl
override fun saveBaseUrl(url: String) {
baseUrl = url
}
override fun getWorkspaceId(): String? = workspaceId
override fun saveWorkspaceId(workspaceId: String) {
this.workspaceId = workspaceId
}
override fun clearBaseUrl() {
baseUrl = null
}
override fun clearWorkspaceId() {
workspaceId = null
}
}
private class InMemoryApiKeyStore(private var apiKey: String?) : ApiKeyStore {
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
this.apiKey = apiKey
return Result.success(Unit)
}
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(apiKey)
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
apiKey = null
return Result.success(Unit)
}
}
private class FakeBoardDetailApiClient : KanbnApiClient {
var workspacesResult: BoardsApiResult<List<WorkspaceSummary>> =
BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
var boardDetailResult: BoardsApiResult<BoardDetail> = BoardsApiResult.Success(sampleBoardDetail())
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 deleteOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
var labelByIdResults: Map<String, BoardsApiResult<LabelDetail>> = emptyMap()
var listWorkspacesCalls: Int = 0
var lastBoardId: String? = null
var lastListId: String? = null
var lastListTitle: String? = null
var movedCardIds: MutableList<String> = mutableListOf()
var deletedCardIds: MutableList<String> = mutableListOf()
var lastMoveTargetListId: String? = null
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 listWorkspaces(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> {
listWorkspacesCalls += 1
return workspacesResult
}
override suspend fun getBoardDetail(
baseUrl: String,
apiKey: String,
boardId: String,
): BoardsApiResult<BoardDetail> {
lastBoardId = boardId
return boardDetailResult
}
override suspend fun renameList(
baseUrl: String,
apiKey: String,
listId: String,
newTitle: String,
): BoardsApiResult<Unit> {
lastListId = listId
lastListTitle = newTitle
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(
baseUrl: String,
apiKey: String,
cardId: String,
targetListId: String,
): BoardsApiResult<Unit> {
movedCardIds += cardId
lastMoveTargetListId = targetListId
return moveOutcomes[cardId] ?: BoardsApiResult.Success(Unit)
}
override suspend fun deleteCard(
baseUrl: String,
apiKey: String,
cardId: String,
): BoardsApiResult<Unit> {
deletedCardIds += cardId
return deleteOutcomes[cardId] ?: BoardsApiResult.Success(Unit)
}
override suspend fun getLabelByPublicId(
baseUrl: String,
apiKey: String,
labelId: String,
): BoardsApiResult<LabelDetail> {
getLabelByPublicIdCalls += labelId
return labelByIdResults[labelId] ?: BoardsApiResult.Failure("Missing fake label")
}
override suspend fun listBoards(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardSummary>> {
return BoardsApiResult.Success(emptyList())
}
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> {
return BoardsApiResult.Success(emptyList())
}
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
workspaceId: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
return BoardsApiResult.Success(BoardSummary("new", name))
}
override suspend fun deleteBoard(
baseUrl: String,
apiKey: String,
boardId: String,
): BoardsApiResult<Unit> {
return BoardsApiResult.Success(Unit)
}
}
private companion object {
fun sampleBoardDetail(): BoardDetail {
return BoardDetail(id = "board-1", title = "Board", lists = emptyList())
}
}
}

View File

@@ -7,6 +7,7 @@ import org.junit.Test
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.AuthResult import space.hackenslacker.kanbn4droid.app.auth.AuthResult
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.SessionStore import space.hackenslacker.kanbn4droid.app.auth.SessionStore
class BoardsRepositoryTest { class BoardsRepositoryTest {
@@ -250,5 +251,13 @@ class BoardsRepositoryTest {
lastDeletedId = boardId lastDeletedId = boardId
return deleteBoardResult return deleteBoardResult
} }
override suspend fun getLabelByPublicId(
baseUrl: String,
apiKey: String,
labelId: String,
): BoardsApiResult<LabelDetail> {
return BoardsApiResult.Failure("Not needed in boards tests")
}
} }
} }

View File

@@ -1,10 +1,11 @@
package space.hackenslacker.kanbn4droid.app.boards package space.hackenslacker.kanbn4droid.app.boards
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle 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
@@ -18,6 +19,7 @@ import org.junit.Test
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.AuthResult import space.hackenslacker.kanbn4droid.app.auth.AuthResult
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.SessionStore import space.hackenslacker.kanbn4droid.app.auth.SessionStore
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -111,25 +113,425 @@ class BoardsViewModelTest {
assertFalse(viewModel.uiState.value.isMutating) assertFalse(viewModel.uiState.value.isMutating)
} }
private fun newViewModel(apiClient: FakeBoardsApiClient): BoardsViewModel { @Test
fun loadDrawerDataSuccessPopulatesProfileAndWorkspaces() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
workspacesResult = BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
)
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-2")
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
val drawer = viewModel.uiState.value.drawer
assertEquals("Alice", drawer.profile?.displayName)
assertEquals(2, drawer.workspaces.size)
assertEquals("ws-2", drawer.activeWorkspaceId)
assertEquals(DrawerDataErrorCode.NONE, drawer.errorCode)
assertTrue(drawer.isWorkspaceInteractionEnabled)
}
@Test
fun workspaceSwitchIgnoresSecondTapWhileInFlight() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
)
listBoardsResult = BoardsApiResult.Success(emptyList())
blockListBoards = CompletableDeferred()
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
viewModel.onWorkspaceSelected("ws-2")
viewModel.onWorkspaceSelected("ws-1")
advanceUntilIdle()
assertEquals("ws-2", viewModel.uiState.value.drawer.activeWorkspaceId)
assertEquals("ws-2", sessionStore.getWorkspaceId())
assertEquals(1, api.listBoardsCalls)
api.blockListBoards?.complete(Unit)
advanceUntilIdle()
}
@Test
fun drawerUnauthorizedEmitsSignOutEvent() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Failure("Server error: 401")
workspacesResult = BoardsApiResult.Failure("Server error: 401")
}
val viewModel = newViewModel(api)
val eventDeferred = async { viewModel.events.first() }
viewModel.loadDrawerData()
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is BoardsUiEvent.ForceSignOut)
}
@Test
fun loadDrawerDataEmptyWorkspaceListSetsEmptyState() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Success(emptyList())
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-stale")
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
val drawer = viewModel.uiState.value.drawer
assertTrue(drawer.workspaces.isEmpty())
assertEquals(null, drawer.activeWorkspaceId)
assertFalse(drawer.isWorkspaceInteractionEnabled)
}
@Test
fun loadDrawerDataEmptyWorkspaceDisablesWorkspaceInteraction() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Failure("Server error: 500")
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = null)
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.drawer.isWorkspaceInteractionEnabled)
}
@Test
fun loadDrawerDataPartialFailureExposesRetryableState() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Failure("Cannot reach server. Check your connection and URL.")
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
}
val viewModel = newViewModel(api)
viewModel.loadDrawerData()
advanceUntilIdle()
val drawer = viewModel.uiState.value.drawer
assertEquals(DrawerDataErrorCode.NETWORK, drawer.errorCode)
assertTrue(drawer.isRetryable)
}
@Test
fun loadDrawerDataWorkspacesFailureDisablesWorkspaceInteraction() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Failure("Server error: 503")
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
val drawer = viewModel.uiState.value.drawer
assertEquals("ws-1", drawer.activeWorkspaceId)
assertFalse(drawer.isWorkspaceInteractionEnabled)
}
@Test
fun retryDrawerDataAfterFailureSucceeds() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResults.addLast(BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."))
usersMeResults.addLast(BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null)))
workspacesResults.addLast(BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."))
workspacesResults.addLast(BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))))
}
val viewModel = newViewModel(api)
viewModel.loadDrawerData()
advanceUntilIdle()
assertTrue(viewModel.uiState.value.drawer.isRetryable)
viewModel.retryDrawerData()
advanceUntilIdle()
val drawer = viewModel.uiState.value.drawer
assertEquals(DrawerDataErrorCode.NONE, drawer.errorCode)
assertFalse(drawer.isRetryable)
assertEquals("Alice", drawer.profile?.displayName)
}
@Test
fun loadDrawerDataIfStaleSkipsFreshDataAndReloadsWhenStale() = runTest {
var now = 1_000L
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
}
val viewModel = newViewModel(apiClient = api, nowProvider = { now })
viewModel.loadDrawerData()
advanceUntilIdle()
assertEquals(1, api.listWorkspacesCalls)
assertEquals(1, api.getCurrentUserCalls)
now += 10_000L
viewModel.loadDrawerDataIfStale()
advanceUntilIdle()
assertEquals(1, api.listWorkspacesCalls)
assertEquals(1, api.getCurrentUserCalls)
now += 180_000L
viewModel.loadDrawerDataIfStale()
advanceUntilIdle()
assertEquals(2, api.listWorkspacesCalls)
assertEquals(2, api.getCurrentUserCalls)
}
@Test
fun loadDrawerDataIfStaleRefetchesImmediatelyAfterFailedLoad() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResults.addLast(BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."))
usersMeResults.addLast(BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null)))
workspacesResults.addLast(BoardsApiResult.Failure("Cannot reach server. Check your connection and URL."))
workspacesResults.addLast(BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))))
}
val viewModel = newViewModel(apiClient = api, nowProvider = { 1_000L })
viewModel.loadDrawerData()
advanceUntilIdle()
assertEquals(DrawerDataErrorCode.NETWORK, viewModel.uiState.value.drawer.errorCode)
assertEquals(1, api.listWorkspacesCalls)
assertEquals(1, api.getCurrentUserCalls)
viewModel.loadDrawerDataIfStale()
advanceUntilIdle()
assertEquals(DrawerDataErrorCode.NONE, viewModel.uiState.value.drawer.errorCode)
assertEquals(2, api.listWorkspacesCalls)
assertEquals(2, api.getCurrentUserCalls)
}
@Test
fun loadDrawerDataFallbackWorkspacePersistsFirstAndTriggersBoardsRefresh() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
)
listBoardsResult = BoardsApiResult.Success(emptyList())
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-missing")
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
assertEquals("ws-1", sessionStore.getWorkspaceId())
assertEquals(1, api.listBoardsCalls)
}
@Test
fun workspaceSwitchFailureRestoresUiAndPersistedWorkspaceId() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
)
listBoardsResults.addLast(BoardsApiResult.Failure("Server error: 500"))
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
viewModel.onWorkspaceSelected("ws-2")
advanceUntilIdle()
assertEquals("ws-1", viewModel.uiState.value.drawer.activeWorkspaceId)
assertEquals("ws-1", sessionStore.getWorkspaceId())
assertEquals(1, api.listBoardsCalls)
}
@Test
fun workspaceSwitchUnauthorizedEmitsForceSignOut() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Success(
listOf(
WorkspaceSummary("ws-1", "Main"),
WorkspaceSummary("ws-2", "Platform"),
),
)
listBoardsResults.addLast(BoardsApiResult.Failure("Server error: 401"))
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
val eventDeferred = async { viewModel.events.first() }
viewModel.onWorkspaceSelected("ws-2")
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is BoardsUiEvent.ForceSignOut)
assertEquals("ws-1", sessionStore.getWorkspaceId())
assertEquals(1, api.listBoardsCalls)
}
@Test
fun workspaceSwitchFailureWithNullPreviousRollsBackPersistedWorkspaceId() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = null))
workspacesResult = BoardsApiResult.Failure("Server error: 500")
listBoardsResults.addLast(BoardsApiResult.Failure("Server error: 500"))
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = null)
val viewModel = newViewModel(api, sessionStore = sessionStore)
viewModel.loadDrawerData()
advanceUntilIdle()
assertEquals(null, viewModel.uiState.value.drawer.activeWorkspaceId)
assertEquals(null, sessionStore.getWorkspaceId())
viewModel.onWorkspaceSelected("ws-2")
advanceUntilIdle()
assertEquals(null, sessionStore.getWorkspaceId())
assertEquals(null, viewModel.uiState.value.drawer.activeWorkspaceId)
assertEquals(1, api.listBoardsCalls)
}
@Test
fun onSettingsAppliedWithCredentialsChangedRefreshesBoardsAndDrawer() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha")))
}
val viewModel = newViewModel(api)
viewModel.onSettingsApplied(credentialsChanged = true)
advanceUntilIdle()
assertTrue(api.listBoardsCalls >= 1)
assertEquals(1, api.getCurrentUserCalls)
assertEquals(1, api.listWorkspacesCalls)
}
@Test
fun onSettingsAppliedWithThemeOnlySkipsNetworkRefresh() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha")))
}
val viewModel = newViewModel(api)
viewModel.onSettingsApplied(credentialsChanged = false)
advanceUntilIdle()
assertEquals(0, api.listBoardsCalls)
assertEquals(0, api.getCurrentUserCalls)
assertEquals(0, api.listWorkspacesCalls)
}
@Test
fun onSettingsAppliedRefreshFailureKeepsCredentialsAndEmitsRetryableError() = runTest {
val api = FakeBoardsApiClient().apply {
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
listBoardsResult = BoardsApiResult.Failure("Cannot reach server. Check your connection and URL.")
}
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
val viewModel = newViewModel(api, sessionStore = sessionStore)
val eventDeferred = async { viewModel.events.first() }
viewModel.onSettingsApplied(credentialsChanged = true)
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is BoardsUiEvent.ShowServerError)
assertEquals("https://kan.bn/", sessionStore.getBaseUrl())
assertEquals("ws-1", sessionStore.getWorkspaceId())
assertTrue(api.listBoardsCalls >= 1)
}
@Test
fun clearSessionUiStateResetsBoardsStateToDefaults() = runTest {
val api = FakeBoardsApiClient().apply {
listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha")))
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
}
val viewModel = newViewModel(api)
viewModel.loadBoards()
viewModel.loadDrawerData()
advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.boards.size)
assertEquals("Alice", viewModel.uiState.value.drawer.profile?.displayName)
viewModel.clearSessionUiState()
val state = viewModel.uiState.value
assertTrue(state.isInitialLoading)
assertFalse(state.isRefreshing)
assertFalse(state.isMutating)
assertTrue(state.boards.isEmpty())
assertTrue(state.templates.isEmpty())
assertFalse(state.isTemplatesLoading)
assertEquals(BoardsDrawerState(), state.drawer)
}
private fun newViewModel(
apiClient: FakeBoardsApiClient,
sessionStore: InMemorySessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1"),
nowProvider: () -> Long = { System.currentTimeMillis() },
): BoardsViewModel {
val repository = BoardsRepository( val repository = BoardsRepository(
sessionStore = InMemorySessionStore("https://kan.bn/"), sessionStore = sessionStore,
apiKeyStore = InMemoryApiKeyStore("api"), apiKeyStore = InMemoryApiKeyStore("api"),
apiClient = apiClient, apiClient = apiClient,
ioDispatcher = UnconfinedTestDispatcher(), ioDispatcher = UnconfinedTestDispatcher(),
) )
return BoardsViewModel(repository) return BoardsViewModel(repository, nowProvider = nowProvider)
} }
private class InMemorySessionStore(private var baseUrl: String?) : SessionStore { private class InMemorySessionStore(
private var baseUrl: String?,
private var workspaceId: String? = null,
) : SessionStore {
override fun getBaseUrl(): String? = baseUrl override fun getBaseUrl(): String? = baseUrl
override fun saveBaseUrl(url: String) { override fun saveBaseUrl(url: String) {
baseUrl = url baseUrl = url
} }
override fun getWorkspaceId(): String? = "ws-1" override fun getWorkspaceId(): String? = workspaceId
override fun saveWorkspaceId(workspaceId: String) { override fun saveWorkspaceId(workspaceId: String) {
this.workspaceId = workspaceId
} }
override fun clearBaseUrl() { override fun clearBaseUrl() {
@@ -137,6 +539,7 @@ class BoardsViewModelTest {
} }
override fun clearWorkspaceId() { override fun clearWorkspaceId() {
workspaceId = null
} }
} }
@@ -156,20 +559,43 @@ class BoardsViewModelTest {
private class FakeBoardsApiClient : KanbnApiClient { private class FakeBoardsApiClient : KanbnApiClient {
var listBoardsResult: BoardsApiResult<List<BoardSummary>> = BoardsApiResult.Success(emptyList()) var listBoardsResult: BoardsApiResult<List<BoardSummary>> = BoardsApiResult.Success(emptyList())
val listBoardsResults = ArrayDeque<BoardsApiResult<List<BoardSummary>>>()
var createBoardResult: BoardsApiResult<BoardSummary> = BoardsApiResult.Success(BoardSummary("new", "New")) var createBoardResult: BoardsApiResult<BoardSummary> = BoardsApiResult.Success(BoardSummary("new", "New"))
var listTemplatesResult: BoardsApiResult<List<BoardTemplate>> = BoardsApiResult.Success(emptyList()) var listTemplatesResult: BoardsApiResult<List<BoardTemplate>> = BoardsApiResult.Success(emptyList())
var deleteBoardResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit) var deleteBoardResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
var workspacesResult: BoardsApiResult<List<WorkspaceSummary>> = BoardsApiResult.Success(emptyList())
val workspacesResults = ArrayDeque<BoardsApiResult<List<WorkspaceSummary>>>()
var usersMeResult: BoardsApiResult<DrawerProfile> = BoardsApiResult.Success(DrawerProfile("User", null))
val usersMeResults = ArrayDeque<BoardsApiResult<DrawerProfile>>()
var blockListBoards: CompletableDeferred<Unit>? = null
var lastDeletedId: String? = null var lastDeletedId: String? = null
var listBoardsCalls: Int = 0
var listWorkspacesCalls: Int = 0
var getCurrentUserCalls: Int = 0
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun getCurrentUser(baseUrl: String, apiKey: String): BoardsApiResult<DrawerProfile> {
getCurrentUserCalls += 1
return usersMeResults.removeFirstOrNull() ?: usersMeResult
}
override suspend fun listWorkspaces(
baseUrl: String,
apiKey: String,
): BoardsApiResult<List<WorkspaceSummary>> {
listWorkspacesCalls += 1
return workspacesResults.removeFirstOrNull() ?: workspacesResult
}
override suspend fun listBoards( override suspend fun listBoards(
baseUrl: String, baseUrl: String,
apiKey: String, apiKey: String,
workspaceId: String, workspaceId: String,
): BoardsApiResult<List<BoardSummary>> { ): BoardsApiResult<List<BoardSummary>> {
return listBoardsResult listBoardsCalls += 1
blockListBoards?.await()
return listBoardsResults.removeFirstOrNull() ?: listBoardsResult
} }
override suspend fun listBoardTemplates( override suspend fun listBoardTemplates(
@@ -194,5 +620,13 @@ class BoardsViewModelTest {
lastDeletedId = boardId lastDeletedId = boardId
return deleteBoardResult return deleteBoardResult
} }
override suspend fun getLabelByPublicId(
baseUrl: String,
apiKey: String,
labelId: String,
): BoardsApiResult<LabelDetail> {
return BoardsApiResult.Failure("Not needed in boards tests")
}
} }
} }

View File

@@ -0,0 +1,423 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import java.time.LocalDate
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.LabelDetail
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary
class CardDetailRepositoryTest {
@Test
fun missingSession_returnsSessionExpiredFailure() = runTest {
val repository = createRepository(
sessionStore = InMemorySessionStore(baseUrl = null),
)
val result = repository.listActivities("card-1")
assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired)
assertEquals(
CardDetailRepository.MISSING_SESSION_MESSAGE,
(result as CardDetailRepository.Result.Failure.SessionExpired).message,
)
}
@Test
fun updateDescription_blankMapsToNull_andPreservesFailureMessage() = runTest {
val apiClient = FakeCardDetailApiClient().apply {
updateCardResult = BoardsApiResult.Failure("Card is archived")
}
val repository = createRepository(apiClient = apiClient)
val result = repository.updateDescription(cardId = "card-1", description = " ")
assertEquals(null, apiClient.lastUpdatedDescriptionNormalized)
assertTrue(result is CardDetailRepository.Result.Failure.Generic)
assertEquals("Card is archived", (result as CardDetailRepository.Result.Failure.Generic).message)
}
@Test
fun updateTitle_authFailureMapsToSessionExpired() = runTest {
val apiClient = FakeCardDetailApiClient().apply {
updateCardResult = BoardsApiResult.Failure("Server error: 401")
}
val repository = createRepository(apiClient = apiClient)
val result = repository.updateTitle(cardId = "card-1", title = " Updated title ")
assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired)
}
@Test
fun listActivities_returnsNewestFirstTopTen() = runTest {
val apiClient = FakeCardDetailApiClient().apply {
listActivitiesResult = BoardsApiResult.Success((1..12).map { index ->
CardActivity(
id = "a-$index",
type = "comment",
text = "Activity $index",
createdAtEpochMillis = index.toLong(),
)
})
}
val repository = createRepository(apiClient = apiClient)
val result = repository.listActivities("card-1")
assertTrue(result is CardDetailRepository.Result.Success)
val activities = (result as CardDetailRepository.Result.Success<List<CardActivity>>).value
assertEquals(10, activities.size)
assertEquals("a-12", activities[0].id)
assertEquals("a-3", activities[9].id)
}
@Test
fun listActivities_authFailureMapsToSessionExpired() = runTest {
val apiClient = FakeCardDetailApiClient().apply {
listActivitiesResult = BoardsApiResult.Failure("Server error: 403")
}
val repository = createRepository(apiClient = apiClient)
val result = repository.listActivities("card-1")
assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired)
}
@Test
fun addComment_success_refreshesActivities() = runTest {
val apiClient = FakeCardDetailApiClient().apply {
addCommentResult = BoardsApiResult.Success(Unit)
listActivitiesResults += BoardsApiResult.Success(emptyList())
listActivitiesResults += BoardsApiResult.Success(
listOf(
CardActivity(
id = "a-1",
type = "comment",
text = "hello",
createdAtEpochMillis = 1L,
),
),
)
}
val repository = createRepository(apiClient = apiClient)
val result = repository.addComment(cardId = "card-1", comment = " hello ")
assertTrue(result is CardDetailRepository.Result.Success)
assertEquals(1, apiClient.addCommentCalls)
assertEquals(2, apiClient.listActivitiesCalls)
assertEquals("hello", apiClient.lastComment)
}
@Test
fun addComment_refreshGenericFailure_returnsSuccessWithPreAddSnapshot() = runTest {
val preAddSnapshot = listOf(
CardActivity(
id = "a-existing",
type = "comment",
text = "existing",
createdAtEpochMillis = 10L,
),
)
val apiClient = FakeCardDetailApiClient().apply {
addCommentResult = BoardsApiResult.Success(Unit)
listActivitiesResults += BoardsApiResult.Success(preAddSnapshot)
listActivitiesResults += BoardsApiResult.Failure("Server temporarily unavailable")
}
val repository = createRepository(apiClient = apiClient)
val result = repository.addComment(cardId = "card-1", comment = "hello")
assertTrue(result is CardDetailRepository.Result.Success)
val activities = (result as CardDetailRepository.Result.Success<List<CardActivity>>).value
assertEquals(preAddSnapshot, activities)
assertEquals(1, apiClient.addCommentCalls)
assertEquals(2, apiClient.listActivitiesCalls)
}
@Test
fun addComment_refreshAuthFailure_mapsToSessionExpired() = runTest {
val apiClient = FakeCardDetailApiClient().apply {
addCommentResult = BoardsApiResult.Success(Unit)
listActivitiesResults += BoardsApiResult.Success(emptyList())
listActivitiesResults += BoardsApiResult.Failure("Server error: 403")
}
val repository = createRepository(apiClient = apiClient)
val result = repository.addComment(cardId = "card-1", comment = "hello")
assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired)
assertEquals(2, apiClient.listActivitiesCalls)
}
@Test
fun addComment_authFailureMapsToSessionExpired() = runTest {
val apiClient = FakeCardDetailApiClient().apply {
addCommentResult = BoardsApiResult.Failure("Authentication failed. Check your API key.")
}
val repository = createRepository(apiClient = apiClient)
val result = repository.addComment(cardId = "card-1", comment = "A")
assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired)
}
@Test
fun listActivities_nonAuthNumericMessage_remainsGenericFailure() = runTest {
val apiClient = FakeCardDetailApiClient().apply {
listActivitiesResult = BoardsApiResult.Failure("Server error: 1401")
}
val repository = createRepository(apiClient = apiClient)
val result = repository.listActivities("card-1")
assertTrue(result is CardDetailRepository.Result.Failure.Generic)
assertEquals("Server error: 1401", (result as CardDetailRepository.Result.Failure.Generic).message)
}
@Test
fun listActivities_forbiddenMessage_mapsToSessionExpired() = runTest {
val apiClient = FakeCardDetailApiClient().apply {
listActivitiesResult = BoardsApiResult.Failure("Forbidden")
}
val repository = createRepository(apiClient = apiClient)
val result = repository.listActivities("card-1")
assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired)
}
@Test
fun updateDueDate_dateOnlyInputNormalizesToUtcMidnightPayloadContract() = runTest {
val apiClient = FakeCardDetailApiClient()
val repository = createRepository(apiClient = apiClient)
val result = repository.updateDueDate(cardId = "card-1", dueDate = LocalDate.parse("2026-03-16"))
assertTrue(result is CardDetailRepository.Result.Success)
assertEquals(LocalDate.of(2026, 3, 16), apiClient.lastUpdatedDueDate)
}
private fun createRepository(
sessionStore: InMemorySessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/"),
apiKeyStore: InMemoryApiKeyStore = InMemoryApiKeyStore("api-key"),
apiClient: FakeCardDetailApiClient = FakeCardDetailApiClient(),
): CardDetailRepository {
return CardDetailRepository(
sessionStore = sessionStore,
apiKeyStore = apiKeyStore,
apiClient = apiClient,
)
}
private class InMemorySessionStore(
private var baseUrl: String?,
private var workspaceId: String? = null,
) : SessionStore {
override fun getBaseUrl(): String? = baseUrl
override fun saveBaseUrl(url: String) {
baseUrl = url
}
override fun getWorkspaceId(): String? = workspaceId
override fun saveWorkspaceId(workspaceId: String) {
this.workspaceId = workspaceId
}
override fun clearBaseUrl() {
baseUrl = null
}
override fun clearWorkspaceId() {
workspaceId = null
}
}
private class InMemoryApiKeyStore(private var apiKey: String?) : ApiKeyStore {
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
this.apiKey = apiKey
return Result.success(Unit)
}
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(apiKey)
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
apiKey = null
return Result.success(Unit)
}
}
private class FakeCardDetailApiClient : KanbnApiClient {
var cardDetailResult: BoardsApiResult<CardDetail> = BoardsApiResult.Success(
CardDetail(
id = "card-1",
title = "Current title",
description = "Current description",
dueDate = LocalDate.of(2026, 3, 1),
listPublicId = "list-1",
index = 0,
tags = emptyList(),
),
)
var updateCardResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
var listActivitiesResult: BoardsApiResult<List<CardActivity>> = BoardsApiResult.Success(emptyList())
val listActivitiesResults: MutableList<BoardsApiResult<List<CardActivity>>> = mutableListOf()
var addCommentResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
var lastUpdatedTitle: String? = null
var lastUpdatedDescription: String? = null
var lastUpdatedDescriptionNormalized: String? = null
var lastUpdatedDueDate: LocalDate? = null
var addCommentCalls: Int = 0
var listActivitiesCalls: Int = 0
var lastComment: String? = null
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
override suspend fun getCardDetail(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<CardDetail> {
return cardDetailResult
}
override suspend fun updateCard(
baseUrl: String,
apiKey: String,
cardId: String,
title: String,
description: String,
dueDate: LocalDate?,
): BoardsApiResult<Unit> {
lastUpdatedTitle = title
lastUpdatedDescription = description
lastUpdatedDescriptionNormalized = description.takeIf { it.isNotBlank() }
lastUpdatedDueDate = dueDate
return updateCardResult
}
override suspend fun listCardActivities(
baseUrl: String,
apiKey: String,
cardId: String,
): BoardsApiResult<List<CardActivity>> {
listActivitiesCalls += 1
if (listActivitiesResults.isNotEmpty()) {
return listActivitiesResults.removeAt(0)
}
return listActivitiesResult
}
override suspend fun addCardComment(
baseUrl: String,
apiKey: String,
cardId: String,
comment: String,
): BoardsApiResult<Unit> {
addCommentCalls += 1
lastComment = comment
return addCommentResult
}
override suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult<List<WorkspaceSummary>> {
return BoardsApiResult.Success(emptyList())
}
override suspend fun listBoards(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardSummary>> {
return BoardsApiResult.Success(emptyList())
}
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> {
return BoardsApiResult.Success(emptyList())
}
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
workspaceId: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
return BoardsApiResult.Success(BoardSummary("board-1", name))
}
override suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Success(Unit)
}
override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<BoardDetail> {
return BoardsApiResult.Success(BoardDetail(id = boardId, title = "Board", lists = emptyList()))
}
override suspend fun renameList(
baseUrl: String,
apiKey: String,
listId: String,
newTitle: String,
): BoardsApiResult<Unit> {
return BoardsApiResult.Success(Unit)
}
override suspend fun createList(
baseUrl: String,
apiKey: String,
boardPublicId: String,
title: String,
appendIndex: Int,
): BoardsApiResult<CreatedEntityRef> {
return BoardsApiResult.Success(CreatedEntityRef("list-1"))
}
override suspend fun createCard(
baseUrl: String,
apiKey: String,
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: List<String>,
): BoardsApiResult<CreatedEntityRef> {
return BoardsApiResult.Success(CreatedEntityRef("card-1"))
}
override suspend fun moveCard(
baseUrl: String,
apiKey: String,
cardId: String,
targetListId: String,
): BoardsApiResult<Unit> {
return BoardsApiResult.Success(Unit)
}
override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Success(Unit)
}
override suspend fun getLabelByPublicId(
baseUrl: String,
apiKey: String,
labelId: String,
): BoardsApiResult<LabelDetail> {
return BoardsApiResult.Success(LabelDetail(labelId, "#000000"))
}
}
}

View File

@@ -0,0 +1,449 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import java.time.LocalDate
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class CardDetailViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
kotlinx.coroutines.Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
kotlinx.coroutines.Dispatchers.resetMain()
}
@Test
fun initialLoad_fillsEditableFieldsAndActivities() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(sampleActivitiesShuffled()),
)
val viewModel = CardDetailViewModel(
cardId = "card-1",
repository = repository,
descriptionDebounceMillis = 800,
)
viewModel.load()
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals("Title", state.title)
assertEquals("Description", state.description)
assertEquals(LocalDate.of(2026, 3, 16), state.dueDate)
assertEquals(listOf("tag-1", "tag-2"), state.tags.map { it.id })
assertEquals(listOf("a-mid", "a-new", "a-old"), state.activities.map { it.id })
assertEquals(listOf(2L, 3L, 1L), state.activities.map { it.createdAtEpochMillis })
assertNull(state.loadErrorMessage)
}
@Test
fun loadSessionExpired_emitsEvent_andRetryPathsDoNotRun() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = DataSourceResult.SessionExpired,
)
val viewModel = CardDetailViewModel(cardId = "card-1", repository = repository)
val eventDeferred = async { viewModel.events.first() }
viewModel.load()
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is CardDetailUiEvent.SessionExpired)
assertTrue(viewModel.uiState.value.isSessionExpired)
viewModel.retryLoad()
viewModel.retryTitleSave()
viewModel.retryDescriptionSave()
viewModel.retryDueDateSave()
viewModel.retryActivities()
advanceUntilIdle()
assertEquals(1, repository.loadCalls)
assertEquals(0, repository.updateTitleCalls)
assertEquals(0, repository.updateDescriptionCalls)
assertEquals(0, repository.updateDueDateCalls)
assertEquals(0, repository.listActivitiesCalls)
}
@Test
fun titleTrimRejectsBlank_andIsolatedFromDescription() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
)
val viewModel = loadedViewModel(this, repository)
viewModel.onDescriptionChanged("pending description")
viewModel.onTitleChanged(" ")
viewModel.onTitleFocusLost()
runCurrent()
assertEquals(0, repository.updateTitleCalls)
assertEquals("Card title is required", viewModel.uiState.value.titleErrorMessage)
assertEquals("pending description", viewModel.uiState.value.description)
assertEquals(" ", viewModel.uiState.value.title)
}
@Test
fun dueDateSetAndClear_areIndependentAndSaved() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
)
val viewModel = loadedViewModel(this, repository)
val nextDate = LocalDate.of(2026, 4, 1)
viewModel.setDueDate(nextDate)
advanceUntilIdle()
viewModel.clearDueDate()
advanceUntilIdle()
assertEquals(listOf(nextDate, null), repository.updateDueDatePayloads)
assertNull(viewModel.uiState.value.dueDate)
assertEquals("Title", viewModel.uiState.value.title)
assertEquals("Description", viewModel.uiState.value.description)
}
@Test
fun descriptionDebounce_savesLatestOnly_andSuppressesDuplicateInflight() = runTest {
val gate = CompletableDeferred<Unit>()
val repository = FakeCardDetailDataSource(
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
updateDescriptionGate = gate,
)
val viewModel = loadedViewModel(this, repository)
viewModel.onDescriptionChanged("a")
advanceTimeBy(799)
runCurrent()
assertEquals(0, repository.updateDescriptionCalls)
viewModel.onDescriptionChanged("ab")
advanceTimeBy(400)
runCurrent()
viewModel.onDescriptionChanged("abc")
advanceTimeBy(800)
runCurrent()
assertEquals(1, repository.updateDescriptionCalls)
assertEquals(listOf("abc"), repository.updateDescriptionPayloads)
viewModel.onDescriptionChanged("abc")
viewModel.onDescriptionFocusLost()
runCurrent()
assertEquals(1, repository.updateDescriptionCalls)
gate.complete(Unit)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isDescriptionSaving)
}
@Test
fun descriptionFocusLossAndOnStop_flushLatestDirtyImmediately() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
)
val viewModel = loadedViewModel(this, repository)
viewModel.onDescriptionChanged("first")
viewModel.onDescriptionFocusLost()
advanceUntilIdle()
viewModel.onDescriptionChanged("second")
viewModel.onStop()
advanceUntilIdle()
assertEquals(listOf("first", "second"), repository.updateDescriptionPayloads)
assertFalse(viewModel.uiState.value.isDescriptionDirty)
}
@Test
fun onStopDuringInflightSave_preservesPendingLatest_andDirtyUntilLatestCompletes() = runTest {
val firstGate = CompletableDeferred<Unit>()
val secondGate = CompletableDeferred<Unit>()
val repository = FakeCardDetailDataSource(
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
updateDescriptionGates = ArrayDeque(listOf(firstGate, secondGate)),
)
val viewModel = loadedViewModel(this, repository)
viewModel.onDescriptionChanged("first")
advanceTimeBy(800)
runCurrent()
assertEquals(listOf("first"), repository.updateDescriptionPayloads)
viewModel.onDescriptionChanged("second")
viewModel.onStop()
runCurrent()
assertEquals(1, repository.updateDescriptionCalls)
firstGate.complete(Unit)
runCurrent()
assertEquals(listOf("first", "second"), repository.updateDescriptionPayloads)
assertTrue(viewModel.uiState.value.isDescriptionDirty)
assertTrue(viewModel.uiState.value.isDescriptionSaving)
assertEquals("second", viewModel.uiState.value.description)
secondGate.complete(Unit)
advanceUntilIdle()
assertEquals(listOf("first", "second"), repository.updateDescriptionPayloads)
assertFalse(viewModel.uiState.value.isDescriptionDirty)
assertFalse(viewModel.uiState.value.isDescriptionSaving)
assertEquals("second", viewModel.uiState.value.description)
}
@Test
fun addComment_success_closesDialog_showsSnackbar_refreshesActivities() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResults = ArrayDeque(
listOf(
DataSourceResult.Success(emptyList()),
),
),
addCommentResult = DataSourceResult.Success(sampleActivitiesShuffled()),
)
val viewModel = loadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first { it is CardDetailUiEvent.ShowSnackbar } }
viewModel.openCommentDialog()
viewModel.onCommentChanged("hello")
viewModel.submitComment()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isCommentDialogOpen)
assertEquals("", viewModel.uiState.value.commentDraft)
assertEquals(1, repository.addCommentCalls)
assertEquals(1, repository.listActivitiesCalls)
assertEquals(listOf("a-mid", "a-new", "a-old"), viewModel.uiState.value.activities.map { it.id })
assertTrue(eventDeferred.await() is CardDetailUiEvent.ShowSnackbar)
}
@Test
fun addComment_failure_keepsDialogOpen_andStoresRetryPayload() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
addCommentResult = DataSourceResult.GenericError("Cannot add comment"),
)
val viewModel = loadedViewModel(this, repository)
viewModel.openCommentDialog()
viewModel.onCommentChanged("hello")
viewModel.submitComment()
advanceUntilIdle()
viewModel.retryAddComment()
advanceUntilIdle()
assertTrue(viewModel.uiState.value.isCommentDialogOpen)
assertEquals("Cannot add comment", viewModel.uiState.value.commentErrorMessage)
assertEquals(2, repository.addCommentCalls)
assertEquals(listOf("hello", "hello"), repository.addCommentPayloads)
}
@Test
fun activitiesRetry_recoversFromFailure() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResults = ArrayDeque(
listOf(
DataSourceResult.GenericError("Network error"),
DataSourceResult.Success(sampleActivitiesShuffled()),
),
),
)
val viewModel = loadedViewModel(this, repository)
assertEquals("Network error", viewModel.uiState.value.activitiesErrorMessage)
viewModel.retryActivities()
advanceUntilIdle()
assertNull(viewModel.uiState.value.activitiesErrorMessage)
assertEquals(listOf("a-mid", "a-new", "a-old"), viewModel.uiState.value.activities.map { it.id })
}
@Test
fun descriptionAndCommentModes_toggleBetweenEditAndPreview() = runTest {
val viewModel = loadedViewModel(
this,
FakeCardDetailDataSource(
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
),
)
viewModel.setDescriptionMode(DescriptionMode.PREVIEW)
viewModel.openCommentDialog()
viewModel.setCommentDialogMode(CommentDialogMode.PREVIEW)
assertEquals(DescriptionMode.PREVIEW, viewModel.uiState.value.descriptionMode)
assertEquals(CommentDialogMode.PREVIEW, viewModel.uiState.value.commentDialogMode)
}
@Test
fun fieldSpecificRetry_usesLastAttemptedPayload() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = DataSourceResult.Success(sampleCardDetail()),
listActivitiesResult = DataSourceResult.Success(emptyList()),
updateTitleResult = DataSourceResult.GenericError("title failed"),
updateDueDateResult = DataSourceResult.GenericError("due failed"),
updateDescriptionResult = DataSourceResult.GenericError("desc failed"),
)
val viewModel = loadedViewModel(this, repository)
viewModel.onTitleChanged("attempt title")
viewModel.onTitleFocusLost()
viewModel.onTitleChanged("different title")
viewModel.retryTitleSave()
val dueAttempt = LocalDate.of(2026, 6, 1)
viewModel.setDueDate(dueAttempt)
viewModel.setDueDate(LocalDate.of(2026, 7, 1))
viewModel.retryDueDateSave()
advanceUntilIdle()
viewModel.onDescriptionChanged("attempt desc")
advanceTimeBy(800)
runCurrent()
viewModel.onDescriptionChanged("different desc")
viewModel.retryDescriptionSave()
runCurrent()
assertEquals(listOf("attempt title", "attempt title"), repository.updateTitlePayloads)
assertEquals(listOf(dueAttempt, LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 1)), repository.updateDueDatePayloads)
assertEquals(listOf("attempt desc", "attempt desc"), repository.updateDescriptionPayloads)
}
private fun loadedViewModel(
scope: kotlinx.coroutines.test.TestScope,
repository: FakeCardDetailDataSource,
): CardDetailViewModel {
return CardDetailViewModel(
cardId = "card-1",
repository = repository,
descriptionDebounceMillis = 800,
).also {
it.load()
scope.advanceUntilIdle()
}
}
private class FakeCardDetailDataSource(
var loadCardResult: DataSourceResult<CardDetail> = DataSourceResult.Success(sampleCardDetail()),
var listActivitiesResult: DataSourceResult<List<CardActivity>> = DataSourceResult.Success(emptyList()),
var listActivitiesResults: ArrayDeque<DataSourceResult<List<CardActivity>>> = ArrayDeque(),
var updateTitleResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit),
var updateDescriptionResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit),
var updateDueDateResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit),
var addCommentResult: DataSourceResult<List<CardActivity>> = DataSourceResult.Success(emptyList()),
var updateDescriptionGate: CompletableDeferred<Unit>? = null,
var updateDescriptionGates: ArrayDeque<CompletableDeferred<Unit>> = ArrayDeque(),
) : CardDetailDataSource {
var loadCalls: Int = 0
var updateTitleCalls: Int = 0
var updateDescriptionCalls: Int = 0
var updateDueDateCalls: Int = 0
var listActivitiesCalls: Int = 0
var addCommentCalls: Int = 0
val updateTitlePayloads: MutableList<String> = mutableListOf()
val updateDescriptionPayloads: MutableList<String?> = mutableListOf()
val updateDueDatePayloads: MutableList<LocalDate?> = mutableListOf()
val addCommentPayloads: MutableList<String> = mutableListOf()
override suspend fun loadCard(cardId: String): DataSourceResult<CardDetail> {
loadCalls += 1
return loadCardResult
}
override suspend fun updateTitle(cardId: String, title: String): DataSourceResult<Unit> {
updateTitleCalls += 1
updateTitlePayloads += title
return updateTitleResult
}
override suspend fun updateDescription(cardId: String, description: String): DataSourceResult<Unit> {
updateDescriptionCalls += 1
updateDescriptionPayloads += description
if (updateDescriptionGates.isNotEmpty()) {
updateDescriptionGates.removeFirst().await()
}
updateDescriptionGate?.await()
return updateDescriptionResult
}
override suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult<Unit> {
updateDueDateCalls += 1
updateDueDatePayloads += dueDate
return updateDueDateResult
}
override suspend fun listActivities(cardId: String): DataSourceResult<List<CardActivity>> {
listActivitiesCalls += 1
if (listActivitiesResults.isNotEmpty()) {
return listActivitiesResults.removeFirst()
}
return listActivitiesResult
}
override suspend fun addComment(cardId: String, comment: String): DataSourceResult<List<CardActivity>> {
addCommentCalls += 1
addCommentPayloads += comment
return addCommentResult
}
}
private companion object {
fun sampleCardDetail(): CardDetail {
return CardDetail(
id = "card-1",
title = "Title",
description = "Description",
dueDate = LocalDate.of(2026, 3, 16),
listPublicId = "list-1",
index = 0,
tags = listOf(
CardDetailTag(id = "tag-1", name = "Tag 1", colorHex = "#111111"),
CardDetailTag(id = "tag-2", name = "Tag 2", colorHex = "#222222"),
),
)
}
fun sampleActivitiesShuffled(): List<CardActivity> {
return listOf(
CardActivity(id = "a-mid", type = "comment", text = "mid", createdAtEpochMillis = 2L),
CardActivity(id = "a-new", type = "comment", text = "new", createdAtEpochMillis = 3L),
CardActivity(id = "a-old", type = "comment", text = "old", createdAtEpochMillis = 1L),
)
}
}
}

View File

@@ -0,0 +1,154 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class MarkdownRendererTest {
@Test
fun render_markdownBoldAndLink_returnsStyledSpanned() {
val renderer = MarkdownRenderer(
htmlToSpanned = { html ->
val spans = mutableListOf<TestSpan>()
if (html.contains("<strong>bold</strong>")) {
spans += TestSpan.Bold
}
if (html.contains("<a href=\"https://kan.bn\">Kan.bn</a>")) {
spans += TestSpan.Link("https://kan.bn")
}
TestSpanned(html, spans)
},
)
val result = renderer.render("This is **bold** and [Kan.bn](https://kan.bn)")
val boldSpans = result.getSpans(0, result.length, TestSpan.Bold::class.java)
val linkSpans = result.getSpans(0, result.length, TestSpan.Link::class.java)
assertTrue(boldSpans.isNotEmpty())
assertTrue(linkSpans.any { it.url == "https://kan.bn" })
}
@Test
fun render_whenRenderingFails_returnsPlainTextFallback() {
val renderer = MarkdownRenderer(
parseToHtml = { throw IllegalStateException("boom") },
htmlToSpanned = { throw AssertionError("htmlToSpanned should not run on fallback") },
)
val result = renderer.render("**keep this literal**")
assertEquals("**keep this literal**", result.toString())
}
@Test
fun render_rawHtml_isEscaped() {
val renderer = MarkdownRenderer(
htmlToSpanned = { html -> TestSpanned(html) },
)
val result = renderer.render("<script>alert(1)</script> **ok**")
assertTrue(result.toString().contains("&lt;script&gt;alert(1)&lt;/script&gt;"))
}
@Test
fun enableLinks_setsLinkMovementMethodOnPreviewTextWidget() {
var applied = false
val fakeMovementMethod = TestMovementMethod()
MarkdownRenderer.enableLinks(
applyMovementMethod = { movementMethod ->
applied = movementMethod === fakeMovementMethod
},
movementMethodProvider = { fakeMovementMethod },
)
assertTrue(applied)
}
private sealed interface TestSpan {
data object Bold : TestSpan
data class Link(val url: String) : TestSpan
}
private class TestMovementMethod : android.text.method.MovementMethod {
override fun initialize(widget: android.widget.TextView?, text: android.text.Spannable?) = Unit
override fun onKeyDown(
widget: android.widget.TextView?,
text: android.text.Spannable?,
keyCode: Int,
event: android.view.KeyEvent?,
): Boolean = false
override fun onKeyUp(
widget: android.widget.TextView?,
text: android.text.Spannable?,
keyCode: Int,
event: android.view.KeyEvent?,
): Boolean = false
override fun onKeyOther(
view: android.widget.TextView?,
text: android.text.Spannable?,
event: android.view.KeyEvent?,
): Boolean = false
override fun onTakeFocus(widget: android.widget.TextView?, text: android.text.Spannable?, direction: Int) = Unit
override fun onTrackballEvent(
widget: android.widget.TextView?,
text: android.text.Spannable?,
event: android.view.MotionEvent?,
): Boolean = false
override fun onTouchEvent(
widget: android.widget.TextView?,
text: android.text.Spannable?,
event: android.view.MotionEvent?,
): Boolean = false
override fun onGenericMotionEvent(
widget: android.widget.TextView?,
text: android.text.Spannable?,
event: android.view.MotionEvent?,
): Boolean = false
override fun canSelectArbitrarily(): Boolean = false
}
private class TestSpanned(
private val raw: String,
private val spans: List<Any> = emptyList(),
) : android.text.Spanned {
override val length: Int
get() = raw.length
override fun get(index: Int): Char = raw[index]
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence = raw.subSequence(startIndex, endIndex)
override fun toString(): String = raw
override fun getSpanStart(tag: Any): Int = if (spans.contains(tag)) 0 else -1
override fun getSpanEnd(tag: Any): Int = if (spans.contains(tag)) raw.length else -1
override fun getSpanFlags(tag: Any): Int = 0
override fun nextSpanTransition(start: Int, limit: Int, kind: Class<*>?): Int = limit
override fun <T : Any> getSpans(start: Int, end: Int, kind: Class<T>): Array<T> {
val filtered = spans.filter { kind.isInstance(it) }.map { requireNotNull(kind.cast(it)) }
@Suppress("UNCHECKED_CAST")
val array = java.lang.reflect.Array.newInstance(kind, filtered.size) as Array<T>
filtered.forEachIndexed { index, element ->
array[index] = element
}
return array
}
}
}

View File

@@ -0,0 +1,71 @@
# Settings View Implementation Handoff (2026-03-18)
## Task 9 Outcome (Required vs Actual)
Required final quality gate scope:
1. Run verification suite:
- `./gradlew :app:test`
- `./gradlew :app:assembleDebug`
- `./gradlew :app:lintDebug`
2. Check short git status (`git status --short`).
3. Produce handoff note with evidence and deferred items.
Actual outcome:
- **Succeeded:** `:app:test` and `:app:assembleDebug` completed with `BUILD SUCCESSFUL`.
- **Failed (blocking):** `:app:lintDebug` completed with `BUILD FAILED` due to 3 lint errors.
- **Succeeded:** short status captured (`?? .kotlin/`).
- **Succeeded:** handoff note written in this worktree and committed.
## Reproducibility Anchors
- **Run context:** local CLI run in worktree `/home/micost/Documentos/Repos/Kanbn4Droid/.worktrees/settings-side-panel`.
- **Run date:** 2026-03-18.
- **Commands executed exactly:**
- `./gradlew :app:test`
- `./gradlew :app:assembleDebug`
- `./gradlew :app:lintDebug`
- `git status --short`
- **Output source:** all evidence in this note comes from those local command outputs in this same worktree.
## Verification Evidence Summary
- `./gradlew :app:test` -> **PASS** (`BUILD SUCCESSFUL`), including `:app:testDebugUnitTest` and `:app:testReleaseUnitTest`.
- `./gradlew :app:assembleDebug` -> **PASS** (`BUILD SUCCESSFUL`), `:app:assembleDebug` up-to-date.
- `./gradlew :app:lintDebug` -> **FAIL** (`BUILD FAILED`), reported `3 errors, 89 warnings`.
- `git status --short` -> `?? .kotlin/`.
## Lint Blocking Errors (All 3)
1. `app/src/main/java/space/hackenslacker/kanbn4droid/app/settings/SettingsPreferencesFragment.kt:25`
- Error: `RestrictedApi` (`Preference.performClick()` call on `KEY_BASE_URL`).
- Remediation hint: avoid direct `performClick()`; trigger editable preference flow through supported public APIs (for example direct navigation/focus flow or extracting edit logic into app-owned method).
2. `app/src/main/java/space/hackenslacker/kanbn4droid/app/settings/SettingsPreferencesFragment.kt:26`
- Error: `RestrictedApi` (`Preference.performClick()` call on `KEY_API_KEY`).
- Remediation hint: same fix strategy as above; remove restricted API calls and replace with app-side explicit edit action.
3. `app/src/main/res/layout/item_card_activity_timeline.xml:27`
- Error: `UseAppTint` (`android:tint` used).
- Remediation hint: replace `android:tint` with `app:tint` and ensure `xmlns:app` is declared on the root element.
## Deferred Items with Exit Criteria
1. Clear lint blocking errors.
- Exit criteria:
- `./gradlew :app:lintDebug` exits 0.
- lint report shows `0 errors`.
- Suggested next commands:
- `./gradlew :app:lintDebug`
- `./gradlew :app:test`
- `./gradlew :app:assembleDebug`
2. Resolve `.kotlin/` untracked handling policy.
- Recommendation: keep `.kotlin/` out of version control and add/confirm ignore coverage in repo-level `.gitignore` for deterministic clean status.
- Exit criteria:
- `git status --short` no longer reports `.kotlin/` after a fresh local build/test run.
- `.kotlin/` is not tracked in commits.
- Suggested next commands:
- `git status --short`
- `git check-ignore -v .kotlin/`

View File

@@ -16,6 +16,8 @@ lifecycle = "2.8.7"
swiperefreshlayout = "1.1.0" swiperefreshlayout = "1.1.0"
recyclerview = "1.3.2" recyclerview = "1.3.2"
activity = "1.9.3" activity = "1.9.3"
preference = "1.2.1"
commonmark = "0.22.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -34,7 +36,9 @@ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecyc
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" }
androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" } androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
androidx-preference = { group = "androidx.preference", name = "preference-ktx", version.ref = "preference" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
commonmark = { group = "org.commonmark", name = "commonmark", version.ref = "commonmark" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }