Compare commits
91 Commits
c820b413aa
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 028c05c0c8 | |||
| 8b2f14c470 | |||
| ca005a4de7 | |||
| fed1c58ae9 | |||
| 3cc5a3e837 | |||
| 4d46c49a6d | |||
| 81cfbe070d | |||
| ed98520de7 | |||
| 542ec5c181 | |||
| 769b893959 | |||
| f3349f5dee | |||
| 8d847ae4ea | |||
| 24fccc4d7e | |||
| 41bb01e40c | |||
| 67fa02525c | |||
| b199aa62e5 | |||
| 03a04b82c5 | |||
| 3188fc472a | |||
| eeffb3de49 | |||
| 149663662b | |||
| d9d751c461 | |||
| f27aa6969d | |||
| 2daad8e7ac | |||
| 964da060ce | |||
| 717c87122d | |||
| 96e971229a | |||
| dd91a62928 | |||
| f9eeff8dcc | |||
| de9b87d312 | |||
| 797da7a1b0 | |||
| 8f2d329368 | |||
| 78b34ecef2 | |||
| 344c5a4faa | |||
| f9625df828 | |||
| a0255c2487 | |||
| 72e23fded8 | |||
| 1bd540b1cd | |||
| dfcdc79856 | |||
| beab9006a3 | |||
| aa987c9e00 | |||
| 7132123ccf | |||
| f85586ddc7 | |||
| 82a3d59105 | |||
| d693c42142 | |||
| eee2f9cb17 | |||
| 70f1558ea3 | |||
| fb5d9e1e5b | |||
| 334a01fc79 | |||
| 2c585cde48 | |||
| 81f95c1559 | |||
| e6f47034bc | |||
| dc493f5037 | |||
| a63799138b | |||
| 28b81c96a0 | |||
| 3d4661cbfd | |||
| 1e5979f5c4 | |||
| 3247892038 | |||
| b936baf564 | |||
| de7bb48fe2 | |||
| c48cd1d525 | |||
| 6d313fdf60 | |||
| 6a18d6679a | |||
| 5e0eff37a6 | |||
| 8022647047 | |||
| 85659f070b | |||
| 7b1c51eae0 | |||
| 3d8b9e4491 | |||
| efe19c794f | |||
| 995a6dcae7 | |||
| 80d4c40f10 | |||
| 6c67628e40 | |||
| 4246d01827 | |||
| 02b9af0e51 | |||
| d9e43a6908 | |||
| b031dae74b | |||
| 235ab9973c | |||
| 81cd654611 | |||
| e72e584fd4 | |||
| a7af727752 | |||
| f5ac01de09 | |||
| 5f5a273d7f | |||
| 4455f0ecd3 | |||
| 89537a57b7 | |||
| 2c40892906 | |||
| c56b9d042a | |||
| e7ad14902d | |||
| 9602b7959f | |||
| a2a54523ef | |||
| 6ea0bd1a2f | |||
| 3cff919222 | |||
| 3af47ba55a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,3 +24,6 @@ captures/
|
|||||||
*.swo
|
*.swo
|
||||||
.kateproject
|
.kateproject
|
||||||
.kateproject.d/
|
.kateproject.d/
|
||||||
|
|
||||||
|
.superpowers/
|
||||||
|
docs/
|
||||||
|
|||||||
257
AGENTS.md
257
AGENTS.md
@@ -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
190
DESIGN.md
Normal 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.
|
||||||
@@ -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)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
6
app/src/main/res/color/workspace_drawer_row_stroke.xml
Normal file
6
app/src/main/res/color/workspace_drawer_row_stroke.xml
Normal 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>
|
||||||
6
app/src/main/res/color/workspace_drawer_row_text.xml
Normal file
6
app/src/main/res/color/workspace_drawer_row_text.xml
Normal 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>
|
||||||
12
app/src/main/res/drawable-night/ic_delete_24.xml
Normal file
12
app/src/main/res/drawable-night/ic_delete_24.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M9,3h6l1,1h4v2h-16v-2h4zM6,8h12l-1,12h-10zM9,10v8h2v-8zM13,10v8h2v-8z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
12
app/src/main/res/drawable-night/ic_filter_list_24.xml
Normal file
12
app/src/main/res/drawable-night/ic_filter_list_24.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M4,7h16v2H4zM7,11h10v2H7zM10,15h4v2h-4z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -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>
|
||||||
12
app/src/main/res/drawable-night/ic_search_24.xml
Normal file
12
app/src/main/res/drawable-night/ic_search_24.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#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>
|
||||||
12
app/src/main/res/drawable-night/ic_select_all_grid_24.xml
Normal file
12
app/src/main/res/drawable-night/ic_select_all_grid_24.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#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>
|
||||||
12
app/src/main/res/drawable/ic_delete_24.xml
Normal file
12
app/src/main/res/drawable/ic_delete_24.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M9,3h6l1,1h4v2h-16v-2h4zM6,8h12l-1,12h-10zM9,10v8h2v-8zM13,10v8h2v-8z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
12
app/src/main/res/drawable/ic_filter_list_24.xml
Normal file
12
app/src/main/res/drawable/ic_filter_list_24.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M4,7h16v2H4zM7,11h10v2H7zM10,15h4v2h-4z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
12
app/src/main/res/drawable/ic_move_cards_horizontal_24.xml
Normal file
12
app/src/main/res/drawable/ic_move_cards_horizontal_24.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M7.5,6l-4.5,6l4.5,6v-4h9v4l4.5,-6l-4.5,-6v4h-9z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
12
app/src/main/res/drawable/ic_search_24.xml
Normal file
12
app/src/main/res/drawable/ic_search_24.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M15.5,14h-0.79l-0.28,-0.27a6,6 0,1 0,-1.41,1.41l0.27,0.28v0.79L20,21.5L21.5,20zM10,14a4,4 0,1 1,0 -8a4,4 0,0 1,0 8z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
12
app/src/main/res/drawable/ic_select_all_grid_24.xml
Normal file
12
app/src/main/res/drawable/ic_select_all_grid_24.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="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>
|
||||||
19
app/src/main/res/drawable/ic_timeline_note_24.xml
Normal file
19
app/src/main/res/drawable/ic_timeline_note_24.xml
Normal 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>
|
||||||
80
app/src/main/res/layout/activity_board_detail.xml
Normal file
80
app/src/main/res/layout/activity_board_detail.xml
Normal 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>
|
||||||
@@ -1,60 +1,73 @@
|
|||||||
<?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_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<com.google.android.material.appbar.MaterialToolbar
|
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
android:id="@+id/boardsToolbar"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent">
|
||||||
android:theme="@style/ThemeOverlay.MaterialComponents.ActionBar"
|
|
||||||
app:title="@string/boards_title" />
|
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
android:id="@+id/boardsSwipeRefresh"
|
android:id="@+id/boardsToolbar"
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_marginTop="?attr/actionBarSize">
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="wrap_content"
|
||||||
|
android:theme="@style/ThemeOverlay.MaterialComponents.ActionBar"
|
||||||
|
app:title="@string/boards_title" />
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/boardsRecyclerView"
|
android:id="@+id/boardsSwipeRefresh"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginTop="?attr/actionBarSize">
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:clipToPadding="false"
|
>
|
||||||
android:padding="16dp" />
|
|
||||||
|
|
||||||
<TextView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/boardsEmptyStateText"
|
android:id="@+id/boardsRecyclerView"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_gravity="center"
|
android:clipToPadding="false"
|
||||||
android:gravity="center"
|
android:padding="16dp" />
|
||||||
android:text="@string/boards_empty_state"
|
|
||||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
<ProgressBar
|
<TextView
|
||||||
android:id="@+id/boardsInitialProgress"
|
android:id="@+id/boardsEmptyStateText"
|
||||||
style="@style/Widget.AppCompat.ProgressBar"
|
android:layout_width="wrap_content"
|
||||||
android:layout_width="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_gravity="center"
|
||||||
android:layout_gravity="center"
|
android:gravity="center"
|
||||||
android:visibility="gone" />
|
android:text="@string/boards_empty_state"
|
||||||
</FrameLayout>
|
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
|
||||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
android:visibility="gone" />
|
||||||
|
|
||||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
<ProgressBar
|
||||||
android:id="@+id/createBoardFab"
|
android:id="@+id/boardsInitialProgress"
|
||||||
android:layout_width="wrap_content"
|
style="@style/Widget.AppCompat.ProgressBar"
|
||||||
android:layout_height="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_gravity="bottom|end"
|
android:layout_height="wrap_content"
|
||||||
android:layout_margin="16dp"
|
android:layout_gravity="center"
|
||||||
android:contentDescription="@string/create_board"
|
android:visibility="gone" />
|
||||||
app:srcCompat="@android:drawable/ic_input_add" />
|
</FrameLayout>
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
|
android:id="@+id/createBoardFab"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom|end"
|
||||||
|
android:layout_margin="16dp"
|
||||||
|
android:contentDescription="@string/create_board"
|
||||||
|
app:srcCompat="@android:drawable/ic_input_add" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|||||||
300
app/src/main/res/layout/activity_card_detail.xml
Normal file
300
app/src/main/res/layout/activity_card_detail.xml
Normal 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>
|
||||||
102
app/src/main/res/layout/dialog_add_card.xml
Normal file
102
app/src/main/res/layout/dialog_add_card.xml
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="24dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingEnd="24dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/addCardTitleLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/card_title">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/addCardTitleInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textCapSentences|text"
|
||||||
|
android:maxLines="1" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:hint="@string/description">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/addCardDescriptionInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="top|start"
|
||||||
|
android:inputType="textMultiLine|textCapSentences"
|
||||||
|
android:minLines="3" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/addCardDueDateRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/addCardDueDateText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/due_date"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/addCardClearDueDateAction"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingStart="8dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingBottom="4dp"
|
||||||
|
android:text="@string/clear_due_date"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/addCardTagSection"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/addCardTagsLabel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/add_card_tags"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.TitleSmall" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/addCardTagsPlaceholderText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/add_card_tags_placeholder"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/addCardTagsContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="vertical" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
65
app/src/main/res/layout/dialog_add_comment.xml
Normal file
65
app/src/main/res/layout/dialog_add_comment.xml
Normal 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>
|
||||||
25
app/src/main/res/layout/dialog_add_list.xml
Normal file
25
app/src/main/res/layout/dialog_add_list.xml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="24dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingEnd="24dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/addListTitleLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/list_title_hint">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/addListTitleInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textCapSentences|text"
|
||||||
|
android:maxLines="1" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
29
app/src/main/res/layout/dialog_filter_tags.xml
Normal file
29
app/src/main/res/layout/dialog_filter_tags.xml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="24dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingEnd="24dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/filterTagsPlaceholderText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/filter_tags_placeholder"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/filterTagsContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:orientation="vertical" />
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
25
app/src/main/res/layout/dialog_search_title.xml
Normal file
25
app/src/main/res/layout/dialog_search_title.xml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="24dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingEnd="24dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/searchTitleLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/search">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/searchTitleInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
49
app/src/main/res/layout/dialog_settings.xml
Normal file
49
app/src/main/res/layout/dialog_settings.xml
Normal 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>
|
||||||
39
app/src/main/res/layout/item_board_card_detail.xml
Normal file
39
app/src/main/res/layout/item_board_card_detail.xml
Normal 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>
|
||||||
57
app/src/main/res/layout/item_board_list_page.xml
Normal file
57
app/src/main/res/layout/item_board_list_page.xml
Normal 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>
|
||||||
57
app/src/main/res/layout/item_card_activity_timeline.xml
Normal file
57
app/src/main/res/layout/item_card_activity_timeline.xml
Normal 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>
|
||||||
27
app/src/main/res/layout/item_workspace_drawer.xml
Normal file
27
app/src/main/res/layout/item_workspace_drawer.xml
Normal 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>
|
||||||
88
app/src/main/res/layout/view_boards_drawer.xml
Normal file
88
app/src/main/res/layout/view_boards_drawer.xml
Normal 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>
|
||||||
16
app/src/main/res/menu/menu_board_detail_main.xml
Normal file
16
app/src/main/res/menu/menu_board_detail_main.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/actionFilterByTag"
|
||||||
|
android:icon="@drawable/ic_filter_list_24"
|
||||||
|
android:title="@string/filter_by_tag"
|
||||||
|
app:showAsAction="always" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/actionSearch"
|
||||||
|
android:icon="@drawable/ic_search_24"
|
||||||
|
android:title="@string/search"
|
||||||
|
app:showAsAction="always" />
|
||||||
|
</menu>
|
||||||
22
app/src/main/res/menu/menu_board_detail_selection.xml
Normal file
22
app/src/main/res/menu/menu_board_detail_selection.xml
Normal 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>
|
||||||
4
app/src/main/res/values/dimens.xml
Normal file
4
app/src/main/res/values/dimens.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<dimen name="boards_drawer_max_width">360dp</dimen>
|
||||||
|
</resources>
|
||||||
@@ -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>
|
||||||
|
|||||||
25
app/src/main/res/xml/settings_preferences.xml
Normal file
25
app/src/main/res/xml/settings_preferences.xml
Normal 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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("<script>alert(1)</script>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/`
|
||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user